Many more tests and fixes
All checks were successful
Build / Build (push) Successful in 3m12s

This commit is contained in:
2026-08-27 17:11:12 +02:00
parent 32b3ef9c4a
commit d2a82b5fe3
204 changed files with 20255 additions and 242 deletions

View File

@@ -0,0 +1,222 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.MoveVolatile;
using PkmnLib.Plugin.Gen7.Scripts.Side;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.MoveVolatile;
/// <summary>
/// Tests for the <see cref="FireGrassPledgeMove"/> volatile script, which implements the combined move of
/// Fire Pledge and Grass Pledge, and the <see cref="SeaOfFireEffect"/> it creates.
/// Bulbapedia (Fire Pledge): "If Fire Pledge and Grass Pledge are used in the same turn, the ally moving
/// second will use Fire Pledge with a power of 150, and create a sea of fire on the target's side of the
/// field for four turns. The sea of fire damages all non-Fire Pokémon on that side of the field for 1/8 of
/// their maximum HP at the end of each turn."
/// </summary>
public class FireGrassPledgeMoveTests
{
/// <summary>
/// Creates a fully mocked test setup. The target's battle side gets a real <see cref="ScriptSet"/> as
/// its volatile script set, so the sea of fire created by the combined move can be inspected and driven.
/// </summary>
private static (FireGrassPledgeMove script, IExecutingMove move, IPokemon target, IBattleSide side, IScriptSet
sideVolatile) CreateTestSetup()
{
var script = new FireGrassPledgeMove();
var move = Substitute.For<IExecutingMove>();
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var sideVolatile = new ScriptSet(side);
side.VolatileScripts.Returns(sideVolatile);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.BattleSide.Returns(side);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
return (script, move, target, side, sideVolatile);
}
/// <summary>
/// Creates a Pokémon substitute of the given type with the given maximum HP, for placing on the side
/// covered by the sea of fire.
/// </summary>
private static IPokemon CreateSidePokemon(string typeName, uint maxHealth)
{
var pokemon = Substitute.For<IPokemon>();
pokemon.Types.Returns(new List<TypeIdentifier> { new(1, typeName) });
pokemon.MaxHealth.Returns(maxHealth);
return pokemon;
}
/// <summary>
/// Helper to count the Damage calls a Pokémon received.
/// </summary>
private static int CountDamageCalls(IPokemon pokemon) =>
pokemon.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "Damage");
/// <summary>
/// Bulbapedia: "the ally moving second will use Fire Pledge" when Fire Pledge and Grass Pledge are
/// combined. The marked choice's move is replaced with Fire Pledge.
/// </summary>
[Test]
public async Task ChangeMove_CombinedMove_ExecutesFirePledge()
{
// Arrange
var (script, _, _, _, _) = CreateTestSetup();
var choice = Substitute.For<IMoveChoice>();
StringKey moveName = "grass_pledge";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("fire_pledge"));
}
/// <summary>
/// Bulbapedia: "the ally moving second will use Fire Pledge with a power of 150".
/// </summary>
[Test]
public async Task ChangeBasePower_CombinedMove_PowerIs150()
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup();
ushort basePower = 80;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)150);
}
/// <summary>
/// Bulbapedia: the combined move will "create a sea of fire on the target's side of the field for four
/// turns." The <see cref="SeaOfFireEffect"/> is added to the target's side.
/// </summary>
[Test]
public async Task OnSecondaryEffect_CombinedMoveHits_AddsSeaOfFireToTargetsSide()
{
// Arrange
var (script, move, target, _, sideVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideVolatile.Get<SeaOfFireEffect>()).IsNotNull();
}
/// <summary>
/// Technical test: if the target has no <see cref="IPokemon.BattleData"/>, no sea of fire is created
/// and the script does not throw.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHasNoBattleData_DoesNothing()
{
// Arrange
var (script, move, target, _, sideVolatile) = CreateTestSetup();
target.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideVolatile.Count).IsEqualTo(0);
}
/// <summary>
/// Bulbapedia: "The sea of fire damages all non-Fire Pokémon on that side of the field for 1/8 of
/// their maximum HP at the end of each turn."
/// </summary>
[Test, Arguments(96u, 12u), Arguments(100u, 12u), Arguments(120u, 15u)]
public async Task SeaOfFireEffect_OnEndTurn_DamagesNonFirePokemonForOneEighthOfMaxHp(uint maxHealth,
uint expectedDamage)
{
// Arrange
var (script, move, target, side, sideVolatile) = CreateTestSetup();
var waterPokemon = CreateSidePokemon("water", maxHealth);
side.Pokemon.Returns(new List<IPokemon?> { waterPokemon });
script.OnSecondaryEffect(move, target, 0);
var seaOfFire = sideVolatile.Get<SeaOfFireEffect>()!;
// Act
seaOfFire.OnEndTurn(side, Substitute.For<IBattle>());
// Assert
var call = waterPokemon.ReceivedCalls().First(c => c.GetMethodInfo().Name == "Damage");
await Assert.That((uint)call.GetArguments()[0]!).IsEqualTo(expectedDamage);
await Assert.That((DamageSource)call.GetArguments()[1]!).IsEqualTo(DamageSource.Misc);
}
/// <summary>
/// Bulbapedia: the sea of fire damages "all non-Fire Pokémon on that side of the field" — Fire-type
/// Pokémon are not hurt by it.
/// </summary>
[Test]
public async Task SeaOfFireEffect_OnEndTurn_DoesNotDamageFirePokemon()
{
// Arrange
var (script, move, target, side, sideVolatile) = CreateTestSetup();
var firePokemon = CreateSidePokemon("fire", 100);
side.Pokemon.Returns(new List<IPokemon?> { firePokemon });
script.OnSecondaryEffect(move, target, 0);
var seaOfFire = sideVolatile.Get<SeaOfFireEffect>()!;
// Act
seaOfFire.OnEndTurn(side, Substitute.For<IBattle>());
// Assert
await Assert.That(CountDamageCalls(firePokemon)).IsEqualTo(0);
}
/// <summary>
/// Bulbapedia: the sea of fire lasts "for four turns", damaging "at the end of each turn." Over any
/// number of end-of-turns, a non-Fire Pokémon on the covered side is damaged exactly four times.
/// </summary>
[Test]
public async Task SeaOfFireEffect_LastsFourTurns_DamagesExactlyFourTimes()
{
// Arrange
var (script, move, target, side, sideVolatile) = CreateTestSetup();
var waterPokemon = CreateSidePokemon("water", 96);
side.Pokemon.Returns(new List<IPokemon?> { waterPokemon });
script.OnSecondaryEffect(move, target, 0);
var seaOfFire = sideVolatile.Get<SeaOfFireEffect>()!;
var battle = Substitute.For<IBattle>();
// Act - more end-of-turns than the sea of fire lasts
for (var i = 0; i < 6; i++)
seaOfFire.OnEndTurn(side, battle);
// Assert
await Assert.That(CountDamageCalls(waterPokemon)).IsEqualTo(4);
}
/// <summary>
/// Technical test: once its duration has run out, the sea of fire removes itself from the side's
/// volatile scripts.
/// </summary>
[Test]
public async Task SeaOfFireEffect_AfterExpiry_HasRemovedItselfFromSide()
{
// Arrange
var (script, move, target, side, sideVolatile) = CreateTestSetup();
side.Pokemon.Returns(new List<IPokemon?>());
script.OnSecondaryEffect(move, target, 0);
var seaOfFire = sideVolatile.Get<SeaOfFireEffect>()!;
var battle = Substitute.For<IBattle>();
// Act
for (var i = 0; i < 5; i++)
seaOfFire.OnEndTurn(side, battle);
// Assert
await Assert.That(sideVolatile.Contains("sea_of_fire_effect")).IsFalse();
}
}

View File

@@ -0,0 +1,180 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.MoveVolatile;
using PkmnLib.Plugin.Gen7.Scripts.Side;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.MoveVolatile;
/// <summary>
/// Tests for the <see cref="FireWaterPledgeMove"/> volatile script, which implements the combined move of
/// Fire Pledge and Water Pledge, and the <see cref="RainbowEffect"/> it creates.
/// Bulbapedia (Fire Pledge): "If Fire Pledge and Water Pledge are used in the same turn, the ally moving
/// second will use Water Pledge with a power of 150, and create a rainbow on the user's side of the field
/// for four turns."
/// </summary>
public class FireWaterPledgeMoveTests
{
/// <summary>
/// Creates a fully mocked test setup. The user's battle side gets a real <see cref="ScriptSet"/> as its
/// volatile script set, so the rainbow created by the combined move can be inspected and driven.
/// </summary>
private static (FireWaterPledgeMove script, IExecutingMove move, IPokemon user, IBattleSide side, IScriptSet
sideVolatile) CreateTestSetup()
{
var script = new FireWaterPledgeMove();
var move = Substitute.For<IExecutingMove>();
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var sideVolatile = new ScriptSet(side);
side.VolatileScripts.Returns(sideVolatile);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.BattleSide.Returns(side);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
move.User.Returns(user);
return (script, move, user, side, sideVolatile);
}
/// <summary>
/// Bulbapedia: "the ally moving second will use Water Pledge" when Fire Pledge and Water Pledge are
/// combined. The marked choice's move is replaced with Water Pledge.
/// </summary>
[Test]
public async Task ChangeMove_CombinedMove_ExecutesWaterPledge()
{
// Arrange
var (script, _, _, _, _) = CreateTestSetup();
var choice = Substitute.For<IMoveChoice>();
StringKey moveName = "fire_pledge";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("water_pledge"));
}
/// <summary>
/// Bulbapedia: "the ally moving second will use Water Pledge with a power of 150".
/// </summary>
[Test]
public async Task ChangeBasePower_CombinedMove_PowerIs150()
{
// Arrange
var (script, move, _, _, _) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
ushort basePower = 80;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)150);
}
/// <summary>
/// Bulbapedia: the combined move will "create a rainbow on the user's side of the field for four turns."
/// The <see cref="RainbowEffect"/> is added to the user's side.
/// </summary>
[Test]
public async Task OnSecondaryEffect_CombinedMoveHits_AddsRainbowToUsersSide()
{
// Arrange
var (script, move, _, _, sideVolatile) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideVolatile.Get<RainbowEffect>()).IsNotNull();
}
/// <summary>
/// Technical test: if the user has no <see cref="IPokemon.BattleData"/>, no rainbow is created and the
/// script does not throw.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserHasNoBattleData_DoesNothing()
{
// Arrange
var (script, move, user, _, sideVolatile) = CreateTestSetup();
user.BattleData.Returns((IPokemonBattleData?)null);
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideVolatile.Count).IsEqualTo(0);
}
/// <summary>
/// Bulbapedia: "The rainbow doubles the probability of additional effects taking place for moves used
/// by that side of the field".
/// </summary>
[Test, Arguments(10f, 20f), Arguments(30f, 60f), Arguments(50f, 100f)]
public async Task RainbowEffect_ChangeEffectChance_DoublesTheChance(float chance, float expected)
{
// Arrange
var (script, move, _, _, sideVolatile) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
script.OnSecondaryEffect(move, target, 0);
var rainbow = sideVolatile.Get<RainbowEffect>()!;
// Act
rainbow.ChangeEffectChance(move, target, 0, ref chance);
// Assert
await Assert.That(chance).IsEqualTo(expected);
}
/// <summary>
/// Bulbapedia: the rainbow is created "on the user's side of the field for four turns." After the
/// fourth end-of-turn since its creation the rainbow must be gone.
/// </summary>
[Test]
public async Task RainbowEffect_AfterFourEndOfTurns_RainbowHasFaded()
{
// Arrange
var (script, move, _, side, sideVolatile) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
script.OnSecondaryEffect(move, target, 0);
var rainbow = sideVolatile.Get<RainbowEffect>()!;
var battle = Substitute.For<IBattle>();
// Act - four end-of-turn ticks, counting the turn the rainbow was created
for (var i = 0; i < 4; i++)
rainbow.OnEndTurn(side, battle);
// Assert
await Assert.That(sideVolatile.Contains("rainbow_effect")).IsFalse();
}
/// <summary>
/// Technical test: the rainbow does not stay around forever; it removes itself from the side once its
/// counter runs out (currently after the fifth end-of-turn).
/// </summary>
[Test]
public async Task RainbowEffect_AfterFiveEndOfTurns_HasRemovedItself()
{
// Arrange
var (script, move, _, side, sideVolatile) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
script.OnSecondaryEffect(move, target, 0);
var rainbow = sideVolatile.Get<RainbowEffect>()!;
var battle = Substitute.For<IBattle>();
// Act
for (var i = 0; i < 5; i++)
rainbow.OnEndTurn(side, battle);
// Assert
await Assert.That(sideVolatile.Contains("rainbow_effect")).IsFalse();
}
}

View File

@@ -0,0 +1,283 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Status;
using PkmnLib.Static.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Facade"/> move script.
/// Gen VII Bulbapedia behavior: "Facade inflicts damage. Its base power doubles from 70 to 140 if the user is
/// poisoned, paralyzed, or burned." Generation VI onwards: "When using Facade, Burn's effect of halving the
/// damage done by physical moves is now ignored."
/// </summary>
public class FacadeTests
{
/// <summary>
/// Creates a fully mocked test setup where the user has the given (or no) non-volatile status in its
/// <see cref="IPokemon.StatusScript"/>, and is using a move of the given category.
/// </summary>
private static (Facade facade, IExecutingMove move, IPokemon target) CreateTestSetup(Script? status,
MoveCategory category = MoveCategory.Physical)
{
var facade = new Facade();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var user = Substitute.For<IPokemon>();
user.StatusScript.Returns(status == null ? new ScriptContainer() : new ScriptContainer(status));
move.User.Returns(user);
var useMove = Substitute.For<IMoveData>();
useMove.Category.Returns(category);
move.UseMove.Returns(useMove);
return (facade, move, target);
}
/// <summary>
/// Bulbapedia: "Its base power doubles from 70 to 140 if the user is poisoned, paralyzed, or burned."
/// The user is burned.
/// </summary>
[Test]
public async Task ChangeBasePower_UserBurned_BasePowerDoubles()
{
// Arrange
var (facade, move, target) = CreateTestSetup(new Burned());
ushort basePower = 70;
// Act
facade.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)140);
}
/// <summary>
/// Bulbapedia: "Its base power doubles from 70 to 140 if the user is poisoned, paralyzed, or burned."
/// The user is paralyzed.
/// </summary>
[Test]
public async Task ChangeBasePower_UserParalyzed_BasePowerDoubles()
{
// Arrange
var (facade, move, target) = CreateTestSetup(new Paralyzed());
ushort basePower = 70;
// Act
facade.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)140);
}
/// <summary>
/// Bulbapedia: "Its base power doubles from 70 to 140 if the user is poisoned, paralyzed, or burned."
/// The user is poisoned.
/// </summary>
[Test]
public async Task ChangeBasePower_UserPoisoned_BasePowerDoubles()
{
// Arrange
var (facade, move, target) = CreateTestSetup(new Poisoned());
ushort basePower = 70;
// Act
facade.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)140);
}
/// <summary>
/// Bulbapedia: "Its base power doubles from 70 to 140 if the user is poisoned, paralyzed, or burned."
/// Bad poison is a form of poison, so a badly poisoned user (<see cref="BadlyPoisoned"/>) also gets the
/// doubled base power.
/// </summary>
[Test]
public async Task ChangeBasePower_UserBadlyPoisoned_BasePowerDoubles()
{
// Arrange
var (facade, move, target) = CreateTestSetup(new BadlyPoisoned());
ushort basePower = 70;
// Act
facade.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)140);
}
/// <summary>
/// Bulbapedia: the base power only doubles "if the user is poisoned, paralyzed, or burned" — with no
/// status the base power is unchanged.
/// </summary>
[Test]
public async Task ChangeBasePower_UserHasNoStatus_BasePowerUnchanged()
{
// Arrange
var (facade, move, target) = CreateTestSetup(null);
ushort basePower = 70;
// Act
facade.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)70);
}
/// <summary>
/// Bulbapedia: the base power only doubles "if the user is poisoned, paralyzed, or burned" — freeze is
/// not one of the boosting statuses.
/// </summary>
[Test]
public async Task ChangeBasePower_UserFrozen_BasePowerUnchanged()
{
// Arrange
var (facade, move, target) = CreateTestSetup(new Frozen());
ushort basePower = 70;
// Act
facade.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)70);
}
/// <summary>
/// Bulbapedia: the base power only doubles "if the user is poisoned, paralyzed, or burned" — sleep is
/// not one of the boosting statuses.
/// </summary>
[Test]
public async Task ChangeBasePower_UserAsleep_BasePowerUnchanged()
{
// Arrange
var (facade, move, target) = CreateTestSetup(new Sleep());
ushort basePower = 70;
// Act
facade.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)70);
}
/// <summary>
/// Technical test: doubling a very large base power clamps at <see cref="ushort.MaxValue"/> instead of
/// overflowing.
/// </summary>
[Test]
public async Task ChangeBasePower_UserBurnedWithLargeBasePower_ClampsAtMaxValue()
{
// Arrange
var (facade, move, target) = CreateTestSetup(new Burned());
ushort basePower = ushort.MaxValue - 100;
// Act
facade.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(ushort.MaxValue);
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "When using Facade, Burn's effect of halving the damage done by
/// physical moves is now ignored."
///
/// <see cref="Facade"/> implements this by pre-doubling the damage so that the halving <see cref="Burned"/>
/// applies afterwards cancels out. Both hooks are registered on the executing move, and
/// <c>ExecutingMoveImpl.CollectScripts</c> yields the move script before the user's status script, so this
/// drives them in that order.
///
/// This test only pins the composition; that each half individually does its part is covered by
/// <see cref="ChangeMoveDamage_BurnedUser_DamageDoubledToOffsetBurn"/> and
/// <see cref="ChangeMoveDamage_BurnedUserPhysicalMoveWithoutFacade_DamageHalved"/>.
/// </summary>
[Test]
public async Task ChangeMoveDamage_BurnedUserUsingFacade_BurnHalvingIgnored()
{
// Arrange
var burned = new Burned();
var (facade, move, target) = CreateTestSetup(burned);
uint damage = 100;
// Act
facade.ChangeMoveDamage(move, target, 0, ref damage);
burned.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(100u);
}
/// <summary>
/// The half of the above that belongs to <see cref="Facade"/>: a burned user's physical damage is doubled,
/// offsetting the halving <see cref="Burned"/> applies later in the hook chain.
/// </summary>
[Test]
public async Task ChangeMoveDamage_BurnedUser_DamageDoubledToOffsetBurn()
{
// Arrange
var (facade, move, target) = CreateTestSetup(new Burned());
uint damage = 100;
// Act
facade.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(200u);
}
/// <summary>
/// The half of the above that belongs to <see cref="Burned"/>: without Facade's compensating hook, a burned
/// user's physical damage is halved. Bulbapedia: burn "halves damage dealt by a burned Pokémon's physical
/// moves".
/// </summary>
[Test]
public async Task ChangeMoveDamage_BurnedUserPhysicalMoveWithoutFacade_DamageHalved()
{
// Arrange
var burned = new Burned();
var (_, move, target) = CreateTestSetup(burned);
uint damage = 100;
// Act
burned.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(50u);
}
/// <summary>
/// Burn is the only status whose damage penalty Facade offsets — a paralyzed user gets the doubled base
/// power but no damage compensation, since paralysis does not reduce damage.
/// </summary>
[Test]
public async Task ChangeMoveDamage_ParalyzedUser_DamageUnchanged()
{
// Arrange
var (facade, move, target) = CreateTestSetup(new Paralyzed());
uint damage = 100;
// Act
facade.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(100u);
}
/// <summary>
/// Burn only halves physical damage, so Facade must not compensate for a special move. Facade itself is
/// physical; this pins the category guard rather than an in-game scenario.
/// </summary>
[Test]
public async Task ChangeMoveDamage_BurnedUserSpecialMove_DamageUnchanged()
{
// Arrange
var (facade, move, target) = CreateTestSetup(new Burned(), MoveCategory.Special);
uint damage = 100;
// Act
facade.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(100u);
}
}

View File

@@ -0,0 +1,210 @@
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.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FairyLock"/> move script and its attached <see cref="FairyLockEffect"/>.
/// Gen VII Bulbapedia behavior: "Fairy Lock prevents all Pokémon (except Ghost types) on the field from
/// switching out or fleeing during their next turn."
/// </summary>
public class FairyLockTests
{
/// <summary>
/// Creates a fully mocked test setup where the battle has a real <see cref="ScriptSet"/> as its volatile
/// script set.
/// </summary>
private static (FairyLock script, IExecutingMove move, IPokemon target, IBattle battle, IScriptSet battleVolatile)
CreateTestSetup()
{
var script = new FairyLock();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
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);
target.BattleData.Returns(battleData);
return (script, move, target, battle, battleVolatile);
}
/// <summary>
/// Creates a mocked Pokémon with the given type names as its <see cref="IPokemon.Types"/>.
/// </summary>
private static IPokemon CreatePokemonWithTypes(params string[] types)
{
var pokemon = Substitute.For<IPokemon>();
pokemon.Types.Returns(types.Select((name, index) => new TypeIdentifier((byte)(index + 1), new StringKey(name)))
.ToList());
return pokemon;
}
/// <summary>
/// Bulbapedia: "Fairy Lock prevents all Pokémon (except Ghost types) on the field from switching out or
/// fleeing during their next turn." Using the move puts the <see cref="FairyLockEffect"/> on the battle's
/// volatile scripts so it affects the whole field.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AddsFairyLockEffectToBattleVolatile()
{
// Arrange
var (script, move, target, _, battleVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(battleVolatile.Contains(ScriptUtils.ResolveName<FairyLockEffect>())).IsTrue();
}
/// <summary>
/// Technical test: without battle data on the target (outside of battle) the secondary effect does
/// nothing and does not throw.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetHasNoBattleData_DoesNotThrow()
{
// Arrange
var script = new FairyLock();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.BattleData.Returns((IPokemonBattleData?)null);
// Act & Assert - should not throw
script.OnSecondaryEffect(move, target, 0);
}
/// <summary>
/// Bulbapedia: "Fairy Lock prevents all Pokémon (except Ghost types) on the field from switching out".
/// A non-Ghost Pokémon is prevented from switching.
/// </summary>
[Test]
public async Task PreventSelfSwitch_NonGhostPokemon_SwitchPrevented()
{
// Arrange
var effect = new FairyLockEffect();
var user = CreatePokemonWithTypes("normal");
var choice = Substitute.For<ISwitchChoice>();
choice.User.Returns(user);
var prevent = false;
// Act
effect.PreventSelfSwitch(choice, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: Fairy Lock prevents all Pokémon "(except Ghost types)" from switching out — a Ghost-type
/// Pokémon can still switch out.
/// </summary>
[Test]
public async Task PreventSelfSwitch_GhostTypePokemon_SwitchNotPrevented()
{
// Arrange
var effect = new FairyLockEffect();
var user = CreatePokemonWithTypes("ghost");
var choice = Substitute.For<ISwitchChoice>();
choice.User.Returns(user);
var prevent = false;
// Act
effect.PreventSelfSwitch(choice, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia: "Fairy Lock prevents all Pokémon (except Ghost types) on the field from switching out or
/// fleeing during their next turn." A non-Ghost Pokémon is prevented from fleeing.
/// </summary>
[Test]
public async Task PreventSelfRunAway_NonGhostPokemon_FleeingPrevented()
{
// Arrange
var effect = new FairyLockEffect();
var user = CreatePokemonWithTypes("normal");
var choice = Substitute.For<IFleeChoice>();
choice.User.Returns(user);
var prevent = false;
// Act
effect.PreventSelfRunAway(choice, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: Fairy Lock prevents all Pokémon "(except Ghost types)" from fleeing — a Ghost-type
/// Pokémon can still flee.
/// </summary>
[Test]
public async Task PreventSelfRunAway_GhostTypePokemon_FleeingNotPrevented()
{
// Arrange
var effect = new FairyLockEffect();
var user = CreatePokemonWithTypes("ghost");
var choice = Substitute.For<IFleeChoice>();
choice.User.Returns(user);
var prevent = false;
// Act
effect.PreventSelfRunAway(choice, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia: the lock applies "during their next turn" — at the end of the turn Fairy Lock was used,
/// the <see cref="FairyLockEffect"/> stays on the battle so the following turn is still locked.
/// </summary>
[Test]
public async Task OnEndTurn_FirstEndOfTurn_EffectRemains()
{
// Arrange
var (script, move, target, battle, battleVolatile) = CreateTestSetup();
script.OnSecondaryEffect(move, target, 0);
var effect = battleVolatile.Get<FairyLockEffect>()!;
// Act - end of the turn Fairy Lock was used
effect.OnEndTurn(battle, battle);
// Assert
await Assert.That(battleVolatile.Contains(ScriptUtils.ResolveName<FairyLockEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: the lock only lasts "during their next turn" — at the end of the turn after Fairy Lock
/// was used, the <see cref="FairyLockEffect"/> removes itself.
/// </summary>
[Test]
public async Task OnEndTurn_SecondEndOfTurn_EffectRemovesItself()
{
// Arrange
var (script, move, target, battle, battleVolatile) = CreateTestSetup();
script.OnSecondaryEffect(move, target, 0);
var effect = battleVolatile.Get<FairyLockEffect>()!;
// Act - end of the turn Fairy Lock was used, then end of the locked turn
effect.OnEndTurn(battle, battle);
effect.OnEndTurn(battle, battle);
// Assert
await Assert.That(battleVolatile.Contains(ScriptUtils.ResolveName<FairyLockEffect>())).IsFalse();
}
}

View File

@@ -0,0 +1,120 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FakeOut"/> move script.
/// Gen VII Bulbapedia behavior: "Fake Out inflicts damage and always makes the target flinch, unless it has
/// the Ability Inner Focus or Shield Dust." and "Fake Out will fail if not used on the first turn the user
/// is out."
/// </summary>
public class FakeOutTests
{
/// <summary>
/// Creates a fully mocked test setup where the user switched in on the given turn and the battle is
/// currently on the given turn.
/// </summary>
private static (FakeOut script, IExecutingMove move) CreateStopSetup(uint switchInTurn, uint currentTurn)
{
var script = new FakeOut();
var move = Substitute.For<IExecutingMove>();
var battle = Substitute.For<IBattle>();
battle.CurrentTurnNumber.Returns(currentTurn);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
battleData.SwitchInTurn.Returns(switchInTurn);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
move.User.Returns(user);
return (script, move);
}
/// <summary>
/// Bulbapedia: "Fake Out will fail if not used on the first turn the user is out." — on the first turn
/// the user is on the field (its switch-in turn), the move is not stopped. This includes Pokémon sent
/// out later in the battle, as the check is against the turn the user came out, not the battle's first
/// turn.
/// </summary>
[Test, Arguments(1u, 1u), Arguments(5u, 5u)]
public async Task StopBeforeMove_FirstTurnOnField_MoveNotStopped(uint switchInTurn, uint currentTurn)
{
// Arrange
var (script, move) = CreateStopSetup(switchInTurn, currentTurn);
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
}
/// <summary>
/// Bulbapedia: "Fake Out will fail if not used on the first turn the user is out." — on any later turn
/// the move is stopped.
/// </summary>
[Test, Arguments(1u, 2u), Arguments(1u, 10u), Arguments(4u, 5u)]
public async Task StopBeforeMove_LaterTurnOnField_MoveStopped(uint switchInTurn, uint currentTurn)
{
// Arrange
var (script, move) = CreateStopSetup(switchInTurn, currentTurn);
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsTrue();
}
/// <summary>
/// Technical test: without battle data on the user (outside of battle) the first-turn check does nothing
/// and the move is not stopped.
/// </summary>
[Test]
public async Task StopBeforeMove_UserHasNoBattleData_MoveNotStopped()
{
// Arrange
var script = new FakeOut();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
move.User.Returns(user);
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
}
/// <summary>
/// Bulbapedia: "Fake Out inflicts damage and always makes the target flinch" — the secondary effect puts
/// a <see cref="FlinchEffect"/> on the target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AddsFlinchEffectToTarget()
{
// Arrange
var script = new FakeOut();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet volatileSet = new ScriptSet(target);
target.Volatile.Returns(volatileSet);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<FlinchEffect>())).IsTrue();
}
}

View File

@@ -0,0 +1,82 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FalseSwipe"/> move script.
/// Gen VII Bulbapedia behavior: "False Swipe inflicts damage, but will leave the target with 1 HP if it would
/// otherwise cause it to faint. If the target has 1 HP remaining, False Swipe will hit and leave the target
/// at 1 HP."
/// </summary>
public class FalseSwipeTests
{
/// <summary>
/// Creates a fully mocked test setup where the target has the given current health.
/// </summary>
private static (FalseSwipe script, IExecutingMove move, IPokemon target) CreateTestSetup(uint currentHealth)
{
var script = new FalseSwipe();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.CurrentHealth.Returns(currentHealth);
return (script, move, target);
}
/// <summary>
/// Bulbapedia: "False Swipe inflicts damage, but will leave the target with 1 HP if it would otherwise
/// cause it to faint." — damage exactly equal to the target's remaining HP is reduced to leave 1 HP.
/// The (1, 1, 0) row verifies "If the target has 1 HP remaining, False Swipe will hit and leave the
/// target at 1 HP."
/// </summary>
[Test, Arguments(50u, 50u, 49u), Arguments(100u, 100u, 99u), Arguments(1u, 1u, 0u)]
public async Task ChangeMoveDamage_DamageExactlyLethal_LeavesTargetWithOneHp(uint currentHealth, uint damage,
uint expectedDamage)
{
// Arrange
var (script, move, target) = CreateTestSetup(currentHealth);
// Act
script.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: "False Swipe inflicts damage, but will leave the target with 1 HP if it would otherwise
/// cause it to faint." — damage exceeding the target's remaining HP is also capped so the target is left
/// at 1 HP.
/// </summary>
[Test, Arguments(50u, 100u, 49u), Arguments(1u, 5u, 0u), Arguments(30u, 4000u, 29u)]
public async Task ChangeMoveDamage_DamageExceedsCurrentHealth_LeavesTargetWithOneHp(uint currentHealth, uint damage,
uint expectedDamage)
{
// Arrange
var (script, move, target) = CreateTestSetup(currentHealth);
// Act
script.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: "False Swipe inflicts damage" — when the damage would not cause the target to faint, it
/// is dealt in full.
/// </summary>
[Test, Arguments(100u, 50u), Arguments(100u, 99u), Arguments(2u, 1u)]
public async Task ChangeMoveDamage_DamageNotLethal_DamageUnchanged(uint currentHealth, uint damage)
{
// Arrange
var (script, move, target) = CreateTestSetup(currentHealth);
var originalDamage = damage;
// Act
script.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(originalDamage);
}
}

View File

@@ -0,0 +1,187 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Plugin.Gen7.Scripts.Side;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Feint"/> move script.
/// Gen VII Bulbapedia behavior: Feint strikes a target that has used Protect or Detect, "lifting the effects
/// of Protect or Detect for the remainder of the turn." Generation VI onwards: "Feint hits a target even if
/// it is protected by Mat Block, Spiky Shield, King's Shield, Baneful Bunker, Obstruct, Silk Trap, or Burning
/// Bulwark and removes that protection for the rest of the turn." and "Feint removes Crafty Shield and Mat
/// Block from the target's team."
/// </summary>
public class FeintTests
{
/// <summary>
/// Creates a fully mocked test setup where the target has a real <see cref="ScriptSet"/> as its volatile
/// script set.
/// </summary>
private static (Feint script, IExecutingMove move, IPokemon target, IScriptSet volatileSet) CreateTestSetup()
{
var script = new Feint();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet volatileSet = new ScriptSet(target);
target.Volatile.Returns(volatileSet);
return (script, move, target, volatileSet);
}
/// <summary>
/// Extends the test setup with a battle side that has a real <see cref="ScriptSet"/> as its volatile
/// scripts, so side-wide protections such as <see cref="CraftyShieldEffect"/> can be attached.
/// </summary>
private static IScriptSet AttachSide(IPokemon target)
{
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet sideScripts = new ScriptSet(side);
side.VolatileScripts.Returns(sideScripts);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.BattleSide.Returns(side);
target.BattleData.Returns(battleData);
return sideScripts;
}
/// <summary>
/// Bulbapedia: Feint strikes "a target that has used Protect or Detect during that turn, lifting the
/// effects of Protect or Detect for the remainder of the turn." — the target's
/// <see cref="ProtectionEffectScript"/> is removed before the hit.
/// </summary>
[Test]
public async Task OnBeforeHit_TargetUsedProtect_ProtectionEffectRemoved()
{
// Arrange
var (script, move, target, volatileSet) = CreateTestSetup();
volatileSet.Add(new ProtectionEffectScript());
// Act
script.OnBeforeHit(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<ProtectionEffectScript>())).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation V onward): "Feint can now inflict damage even if the target has not used
/// Protect or Detect" — with no protection on the target, the hook simply does nothing and does not
/// throw.
/// </summary>
[Test]
public async Task OnBeforeHit_TargetNotProtected_DoesNothing()
{
// Arrange
var (script, move, target, volatileSet) = CreateTestSetup();
// Act
script.OnBeforeHit(move, target, 0);
// Assert
await Assert.That(volatileSet.Count).IsEqualTo(0);
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "Feint hits a target even if it is protected by Mat Block,
/// Spiky Shield, King's Shield, Baneful Bunker, Obstruct, Silk Trap, or Burning Bulwark and removes that
/// protection for the rest of the turn." — a target under Spiky Shield has its
/// <see cref="SpikyShieldEffect"/> removed.
/// </summary>
[Test]
public async Task OnBeforeHit_TargetUnderSpikyShield_ProtectionEffectRemoved()
{
// Arrange
var (script, move, target, volatileSet) = CreateTestSetup();
volatileSet.Add(new SpikyShieldEffect());
// Act
script.OnBeforeHit(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<SpikyShieldEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "Feint hits a target even if it is protected by Mat Block,
/// Spiky Shield, King's Shield, Baneful Bunker, Obstruct, Silk Trap, or Burning Bulwark and removes that
/// protection for the rest of the turn." — a target under King's Shield has its <see cref="KingsShieldEffect"/>
/// effect removed.
/// </summary>
[Test]
public async Task OnBeforeHit_TargetUnderKingsShield_ProtectionEffectRemoved()
{
// Arrange
var (script, move, target, volatileSet) = CreateTestSetup();
volatileSet.Add(new KingsShieldEffect());
// Act
script.OnBeforeHit(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<KingsShieldEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "Feint hits a target even if it is protected by Mat Block,
/// Spiky Shield, King's Shield, Baneful Bunker, Obstruct, Silk Trap, or Burning Bulwark and removes that
/// protection for the rest of the turn." — a target under Baneful Bunker has its
/// <see cref="BanefulBunkerEffect"/> removed.
/// </summary>
[Test]
public async Task OnBeforeHit_TargetUnderBanefulBunker_ProtectionEffectRemoved()
{
// Arrange
var (script, move, target, volatileSet) = CreateTestSetup();
volatileSet.Add(new BanefulBunkerEffect());
// Act
script.OnBeforeHit(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<BanefulBunkerEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "Feint removes Crafty Shield and Mat Block from the target's
/// team." — the <see cref="CraftyShieldEffect"/> is removed from the target's side.
/// </summary>
[Test]
public async Task OnBeforeHit_CraftyShieldOnTargetSide_SideEffectRemoved()
{
// Arrange
var (script, move, target, _) = CreateTestSetup();
var sideScripts = AttachSide(target);
sideScripts.Add(new CraftyShieldEffect());
// Act
script.OnBeforeHit(move, target, 0);
// Assert
await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName<CraftyShieldEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "Feint removes Crafty Shield and Mat Block from the target's
/// team." — the <see cref="MatBlockEffect"/> is removed from the target's side.
/// </summary>
[Test]
public async Task OnBeforeHit_MatBlockOnTargetSide_SideEffectRemoved()
{
// Arrange
var (script, move, target, _) = CreateTestSetup();
var sideScripts = AttachSide(target);
sideScripts.Add(new MatBlockEffect());
// Act
script.OnBeforeHit(move, target, 0);
// Assert
await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName<MatBlockEffect>())).IsFalse();
}
}

View File

@@ -0,0 +1,116 @@
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="FellStinger"/> move script.
/// Gen VII Bulbapedia behavior ("Generation VII onwards"): "It now raises the user's Attack stat by three
/// stages if it causes the targeted Pokémon to faint."
/// </summary>
public class FellStingerTests
{
/// <summary>
/// Creates a fully mocked test setup for Fell Stinger tests.
/// </summary>
private static (FellStinger script, IExecutingMove move, IPokemon user, IPokemon target) CreateTestSetup(
bool targetFainted)
{
var script = new FellStinger();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
target.IsFainted.Returns(targetFainted);
return (script, move, user, target);
}
/// <summary>
/// Helper to find the first ChangeStatBoost call received by a Pokémon substitute.
/// </summary>
private static object[]? GetStatBoostArguments(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
return call?.GetArguments()!;
}
/// <summary>
/// Bulbapedia: "It now raises the user's Attack stat by three stages if it causes the targeted Pokémon
/// to faint."
/// When the target fainted from the hit, the user's Attack stat is boosted.
/// </summary>
[Test]
public async Task OnAfterHits_TargetFainted_RaisesUserAttack()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(true);
// Act
script.OnAfterHits(move, target);
// Assert
var arguments = GetStatBoostArguments(user);
await Assert.That(arguments).IsNotNull();
await Assert.That((Statistic)arguments![0]!).IsEqualTo(Statistic.Attack);
}
/// <summary>
/// Bulbapedia: "It now raises the user's Attack stat by three stages if it causes the targeted Pokémon
/// to faint."
/// In Generation VII the boost is three stages (up from two stages in Generation VI).
/// </summary>
[Test]
public async Task OnAfterHits_TargetFainted_AttackRaisedByThreeStages()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(true);
// Act
script.OnAfterHits(move, target);
// Assert
var arguments = GetStatBoostArguments(user);
await Assert.That(arguments).IsNotNull();
await Assert.That((sbyte)arguments![1]!).IsEqualTo((sbyte)3);
}
/// <summary>
/// Bulbapedia: "It now raises the user's Attack stat by three stages if it causes the targeted Pokémon
/// to faint."
/// The boost is applied by the user to itself, so it must be self-inflicted and not forced.
/// </summary>
[Test]
public async Task OnAfterHits_TargetFainted_BoostIsSelfInflictedAndNotForced()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(true);
// Act
script.OnAfterHits(move, target);
// Assert
var arguments = GetStatBoostArguments(user);
await Assert.That(arguments).IsNotNull();
await Assert.That((bool)arguments![2]!).IsTrue();
await Assert.That((bool)arguments[3]!).IsFalse();
}
/// <summary>
/// Bulbapedia: the Attack boost only happens "if it causes the targeted Pokémon to faint".
/// If the target survives the hit, no stat boost is applied.
/// </summary>
[Test]
public async Task OnAfterHits_TargetNotFainted_NoStatBoost()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(false);
// Act
script.OnAfterHits(move, target);
// Assert - ChangeStatBoost should never be called. (Checked via ReceivedCalls, as arg matchers cannot
// be used with EventBatchId parameters: its parameterless constructor generates a random Guid.)
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
}
}

View File

@@ -0,0 +1,102 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FinalGambit"/> move script.
/// Bulbapedia: "Final Gambit causes the user to faint, and the target receives damage equal to the user's
/// remaining HP before using Final Gambit."
/// </summary>
public class FinalGambitTests
{
/// <summary>
/// Creates a fully mocked test setup for Final Gambit tests.
/// </summary>
private static (FinalGambit script, IExecutingMove move, IPokemon user, IPokemon target) CreateTestSetup(
uint userCurrentHealth)
{
var script = new FinalGambit();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.CurrentHealth.Returns(userCurrentHealth);
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
return (script, move, user, target);
}
/// <summary>
/// Helper to extract the damage amount from the user's received Damage calls.
/// </summary>
private static uint? GetDamageAmount(IPokemon pokemon)
{
var call = pokemon.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 pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (DamageSource)call.GetArguments()[1]! : null;
}
/// <summary>
/// Bulbapedia: "the target receives damage equal to the user's remaining HP before using Final Gambit."
/// The move damage is replaced with the user's current HP, regardless of what the damage formula produced.
/// </summary>
[Test, Arguments(1u), Arguments(150u), Arguments(400u)]
public async Task ChangeMoveDamage_SetsDamageToUserRemainingHp(uint userCurrentHealth)
{
// Arrange
var (script, move, _, target) = CreateTestSetup(userCurrentHealth);
uint damage = 9999;
// Act
script.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(userCurrentHealth);
}
/// <summary>
/// Bulbapedia: "Final Gambit causes the user to faint".
/// After the move hits, the user takes at least its full remaining HP as damage, so it always faints.
/// </summary>
[Test]
public async Task OnSecondaryEffect_MoveHits_UserTakesAtLeastItsRemainingHpAsDamage()
{
// Arrange
const uint currentHealth = 100;
var (script, move, user, target) = CreateTestSetup(currentHealth);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - the self-damage is at least the user's remaining HP, so the user faints
var damage = GetDamageAmount(user);
await Assert.That(damage).IsNotNull();
await Assert.That(damage!.Value >= currentHealth).IsTrue();
}
/// <summary>
/// Bulbapedia: "The move's side effect is not considered as inflicting damage to the user, and so if the
/// user is holding a Focus Band, Focus Sash, or has Sturdy, they will not activate and the user will
/// still faint."
/// The self-inflicted faint is therefore indirect damage (<see cref="DamageSource.Misc"/>), not move damage.
/// </summary>
[Test]
public async Task OnSecondaryEffect_MoveHits_SelfFaintUsesMiscDamageSource()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(100);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageSource(user)!.Value).IsEqualTo(DamageSource.Misc);
}
}

View File

@@ -0,0 +1,224 @@
using PkmnLib.Dynamic.Events;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FireFang"/> move script.
/// Gen VII Bulbapedia behavior (Generation IV base text, unchanged through Gen VII): "Fire Fang deals damage
/// and has a 10% chance of burning the opponent. It also has an independent 10% chance of causing the target
/// to flinch, if the user attacks before the target."
/// </summary>
public class FireFangTests
{
/// <summary>
/// Creates a fully mocked test setup for Fire Fang tests. The target has not yet moved this turn when
/// <paramref name="queue"/> contains a choice for it.
/// </summary>
private static (FireFang script, IExecutingMove move, IPokemon user, IPokemon target, IBattleRandom random,
IScriptSet targetVolatile) CreateTestSetup(BattleChoiceQueue? queue)
{
var script = new FireFang();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var random = Substitute.For<IBattleRandom>();
var battle = Substitute.For<IBattle>();
battle.Random.Returns(random);
battle.ChoiceQueue.Returns(queue);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
var targetVolatile = Substitute.For<IScriptSet>();
target.Volatile.Returns(targetVolatile);
return (script, move, user, target, random, targetVolatile);
}
/// <summary>
/// Creates a choice queue containing a single yet-to-execute move choice for the given Pokémon.
/// </summary>
private static BattleChoiceQueue CreateQueueWithChoiceFor(IPokemon pokemon)
{
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(pokemon);
return new BattleChoiceQueue([choice]);
}
/// <summary>
/// Helper to check whether a Pokémon received any SetStatus call. (Checked via ReceivedCalls, as arg
/// matchers cannot be used for <see cref="EventBatchId"/> parameters: its parameterless constructor
/// generates a random Guid, which breaks NSubstitute's argument specification binding.)
/// </summary>
private static bool ReceivedSetStatus(IPokemon pokemon) =>
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus");
/// <summary>
/// Bulbapedia: "Fire Fang deals damage and has a 10% chance of burning the opponent."
/// When the burn roll succeeds, the target is burned by the user.
/// </summary>
[Test]
public void OnSecondaryEffect_BurnRollSucceeds_TargetIsBurned()
{
// Arrange
var (script, move, user, target, random, _) = CreateTestSetup(new BattleChoiceQueue([]));
random.EffectChance(10, move, target, 0).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.Received(1).SetStatus("burned", user);
}
/// <summary>
/// Bulbapedia: "has a 10% chance of burning the opponent."
/// The burn roll is made with a 10 percent chance.
/// </summary>
[Test]
public void OnSecondaryEffect_BurnRoll_UsesTenPercentChance()
{
// Arrange
var (script, move, _, target, random, _) = CreateTestSetup(new BattleChoiceQueue([]));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
random.Received(1).EffectChance(10, move, target, 0);
}
/// <summary>
/// Bulbapedia: "has a 10% chance of burning the opponent."
/// When the burn roll fails, the target is not burned.
/// </summary>
[Test]
public async Task OnSecondaryEffect_BurnRollFails_TargetIsNotBurned()
{
// Arrange
var (script, move, _, target, random, _) = CreateTestSetup(new BattleChoiceQueue([]));
random.EffectChance(10, move, target, 0).Returns(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedSetStatus(target)).IsFalse();
}
/// <summary>
/// Bulbapedia: "It also has an independent 10% chance of causing the target to flinch, if the user
/// attacks before the target."
/// The target still has a queued choice this turn (so the user attacked first), and the flinch roll
/// succeeds: a <see cref="FlinchEffect"/> is added to the target.
/// </summary>
[Test]
public void OnSecondaryEffect_UserAttacksBeforeTargetAndFlinchRollSucceeds_TargetFlinches()
{
// Arrange
var (script, move, _, target, random, targetVolatile) = CreateTestSetup(null);
var queue = CreateQueueWithChoiceFor(target);
target.BattleData!.Battle.ChoiceQueue.Returns(queue);
random.EffectChance(10, move, target, 0).Returns(true, true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
targetVolatile.Received(1).Add(Arg.Any<FlinchEffect>());
}
/// <summary>
/// Bulbapedia: "It also has an independent 10% chance of causing the target to flinch".
/// The flinch chance is independent of the burn chance: even when the burn roll fails, a successful
/// flinch roll still causes the flinch.
/// </summary>
[Test]
public async Task OnSecondaryEffect_BurnRollFailsButFlinchRollSucceeds_TargetStillFlinches()
{
// Arrange
var (script, move, _, target, random, targetVolatile) = CreateTestSetup(null);
var queue = CreateQueueWithChoiceFor(target);
target.BattleData!.Battle.ChoiceQueue.Returns(queue);
// First roll (burn) fails, second roll (flinch) succeeds.
random.EffectChance(10, move, target, 0).Returns(false, true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedSetStatus(target)).IsFalse();
targetVolatile.Received(1).Add(Arg.Any<FlinchEffect>());
}
/// <summary>
/// Bulbapedia: the flinch can only happen "if the user attacks before the target."
/// The target has already moved this turn (no queued choice for it remains), so no flinch is applied
/// even though the rolls succeed.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetAlreadyMoved_TargetDoesNotFlinch()
{
// Arrange
var (script, move, _, target, random, targetVolatile) = CreateTestSetup(null);
// The queue only holds a choice for some other Pokémon; the target's choice already executed.
var queue = CreateQueueWithChoiceFor(Substitute.For<IPokemon>());
target.BattleData!.Battle.ChoiceQueue.Returns(queue);
random.EffectChance(10, move, target, 0).Returns(true, true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
targetVolatile.DidNotReceive().Add(Arg.Any<Script>());
}
/// <summary>
/// Technical test: when the battle has no active choice queue, the flinch check is skipped without
/// throwing, while the burn effect can still apply.
/// </summary>
[Test]
public void OnSecondaryEffect_NoChoiceQueue_BurnStillAppliesButNoFlinch()
{
// Arrange
var (script, move, user, target, random, targetVolatile) = CreateTestSetup(null);
random.EffectChance(10, move, target, 0).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.Received(1).SetStatus("burned", user);
targetVolatile.DidNotReceive().Add(Arg.Any<Script>());
}
/// <summary>
/// Technical test: if the target has no <see cref="IPokemon.BattleData"/>, the script does nothing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHasNoBattleData_DoesNothing()
{
// Arrange
var script = new FireFang();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.BattleData.Returns((IPokemonBattleData?)null);
var targetVolatile = Substitute.For<IScriptSet>();
target.Volatile.Returns(targetVolatile);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedSetStatus(target)).IsFalse();
targetVolatile.DidNotReceive().Add(Arg.Any<Script>());
}
}

View File

@@ -0,0 +1,254 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.MoveVolatile;
using PkmnLib.Static.Moves;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FirePledge"/> move script.
/// Bulbapedia: "When two allies attempt to use Fire Pledge and either Water Pledge or Grass Pledge on the
/// same turn, the ally moving first will not itself use a move, but instead the ally moving second will use
/// a combined attack with a power of 150 and an additional effect immediately after it".
/// </summary>
public class FirePledgeTests
{
/// <summary>
/// Creates a move choice for a Pokémon on the given side, using a move with the given name.
/// The choice gets a real <see cref="ScriptSet"/> as its volatile set, so combination markers can be
/// added to and read from it.
/// </summary>
private static IMoveChoice CreateQueuedChoice(string moveName, byte sideIndex)
{
var choice = Substitute.For<IMoveChoice>();
choice.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var pokemon = Substitute.For<IPokemon>();
var battleData = Substitute.For<IPokemonBattleData>();
battleData.SideIndex.Returns(sideIndex);
pokemon.BattleData.Returns(battleData);
choice.User.Returns(pokemon);
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new StringKey(moveName));
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
choice.ChosenMove.Returns(learnedMove);
var choiceVolatile = new ScriptSet(choice);
choice.Volatile.Returns(choiceVolatile);
return choice;
}
/// <summary>
/// Creates a fully mocked test setup for Fire Pledge tests. The user is on side 0.
/// </summary>
private static (FirePledge script, IExecutingMove move, IMoveChoice ownChoice) CreateTestSetup(
BattleChoiceQueue? queue)
{
var script = new FirePledge();
var move = Substitute.For<IExecutingMove>();
var battle = Substitute.For<IBattle>();
battle.ChoiceQueue.Returns(queue);
move.Battle.Returns(battle);
var user = Substitute.For<IPokemon>();
var userBattleData = Substitute.For<IPokemonBattleData>();
userBattleData.SideIndex.Returns((byte)0);
user.BattleData.Returns(userBattleData);
move.User.Returns(user);
var ownChoice = CreateQueuedChoice("fire_pledge", 0);
move.MoveChoice.Returns(ownChoice);
return (script, move, ownChoice);
}
/// <summary>
/// Bulbapedia: "the ally moving first will not itself use a move".
/// An ally on the same side still has Water Pledge queued, so this Fire Pledge is stopped.
/// </summary>
[Test]
public async Task StopBeforeMove_AllyWaterPledgeQueued_StopsMove()
{
// Arrange
var allyChoice = CreateQueuedChoice("water_pledge", 0);
var (script, move, _) = CreateTestSetup(new BattleChoiceQueue([allyChoice]));
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsTrue();
}
/// <summary>
/// Bulbapedia: "If Fire Pledge and Water Pledge are used in the same turn, the ally moving second will
/// use Water Pledge with a power of 150, and create a rainbow on the user's side of the field for four
/// turns."
/// The queued ally choice is marked with the <see cref="FireWaterPledgeMove"/> volatile that implements
/// the combined move.
/// </summary>
[Test]
public async Task StopBeforeMove_AllyWaterPledgeQueued_MarksAllyChoiceAsCombinedFireWaterPledge()
{
// Arrange
var allyChoice = CreateQueuedChoice("water_pledge", 0);
var (script, move, _) = CreateTestSetup(new BattleChoiceQueue([allyChoice]));
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(allyChoice.Volatile.Contains<FireWaterPledgeMove>()).IsTrue();
}
/// <summary>
/// Bulbapedia: "the ally moving first will not itself use a move".
/// An ally on the same side still has Grass Pledge queued, so this Fire Pledge is stopped.
/// </summary>
[Test]
public async Task StopBeforeMove_AllyGrassPledgeQueued_StopsMove()
{
// Arrange
var allyChoice = CreateQueuedChoice("grass_pledge", 0);
var (script, move, _) = CreateTestSetup(new BattleChoiceQueue([allyChoice]));
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsTrue();
}
/// <summary>
/// Bulbapedia: "If Fire Pledge and Grass Pledge are used in the same turn, the ally moving second will
/// use Fire Pledge with a power of 150, and create a sea of fire on the target's side of the field for
/// four turns."
/// The queued ally choice is marked with the <see cref="FireGrassPledgeMove"/> volatile that implements
/// the combined move.
/// </summary>
[Test]
public async Task StopBeforeMove_AllyGrassPledgeQueued_MarksAllyChoiceAsCombinedFireGrassPledge()
{
// Arrange
var allyChoice = CreateQueuedChoice("grass_pledge", 0);
var (script, move, _) = CreateTestSetup(new BattleChoiceQueue([allyChoice]));
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(allyChoice.Volatile.Contains<FireGrassPledgeMove>()).IsTrue();
}
/// <summary>
/// Bulbapedia: the combination only happens when allies use "Fire Pledge and either Water Pledge or
/// Grass Pledge on the same turn".
/// No Pledge move is queued by an ally, so Fire Pledge executes normally.
/// </summary>
[Test]
public async Task StopBeforeMove_NoPledgeMoveQueued_DoesNotStop()
{
// Arrange
var allyChoice = CreateQueuedChoice("tackle", 0);
var (script, move, _) = CreateTestSetup(new BattleChoiceQueue([allyChoice]));
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
}
/// <summary>
/// Bulbapedia: "When two allies attempt to use Fire Pledge and either Water Pledge or Grass Pledge on
/// the same turn" — a Pledge move queued by an opponent does not combine.
/// </summary>
[Test]
public async Task StopBeforeMove_OpponentWaterPledgeQueued_DoesNotStop()
{
// Arrange
var opponentChoice = CreateQueuedChoice("water_pledge", 1);
var (script, move, _) = CreateTestSetup(new BattleChoiceQueue([opponentChoice]));
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
await Assert.That(opponentChoice.Volatile.Contains<FireWaterPledgeMove>()).IsFalse();
}
/// <summary>
/// Bulbapedia: "the ally moving second will use Fire Pledge with a power of 150".
/// When this Fire Pledge choice is itself the combined move of Grass Pledge and Fire Pledge (marked
/// with <see cref="FireGrassPledgeMove"/>), it must execute instead of deferring again.
/// </summary>
[Test]
public async Task StopBeforeMove_ChoiceIsCombinedFireGrassPledge_DoesNotStop()
{
// Arrange
var allyChoice = CreateQueuedChoice("grass_pledge", 0);
var (script, move, ownChoice) = CreateTestSetup(new BattleChoiceQueue([allyChoice]));
ownChoice.Volatile.Add(new FireGrassPledgeMove());
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
await Assert.That(allyChoice.Volatile.Contains<FireGrassPledgeMove>()).IsFalse();
}
/// <summary>
/// Bulbapedia: "the ally moving second will use Water Pledge with a power of 150".
/// When this choice is the combined move of Fire Pledge and Water Pledge (marked with
/// <see cref="FireWaterPledgeMove"/>), it must execute instead of deferring again.
/// </summary>
[Test]
public async Task StopBeforeMove_ChoiceIsCombinedFireWaterPledge_DoesNotStop()
{
// Arrange
var allyChoice = CreateQueuedChoice("water_pledge", 0);
var (script, move, ownChoice) = CreateTestSetup(new BattleChoiceQueue([allyChoice]));
ownChoice.Volatile.Add(new FireWaterPledgeMove());
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
await Assert.That(allyChoice.Volatile.Contains<FireWaterPledgeMove>()).IsFalse();
}
/// <summary>
/// Technical test: when the battle has no active choice queue, the move executes normally without
/// throwing.
/// </summary>
[Test]
public async Task StopBeforeMove_NullChoiceQueue_DoesNotStop()
{
// Arrange
var (script, move, _) = CreateTestSetup(null);
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
}
}

View File

@@ -0,0 +1,245 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
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="FireSpin"/> move script and its <see cref="FireSpinEffect"/> volatile.
/// Gen VII Bulbapedia behavior: Fire Spin inflicts damage and traps the target. Generation V: "it now lasts
/// for four to five turns." Generation VI onwards: "The end turn damage of Fire Spin is increased from 1/16
/// to 1/8 of the target's maximum HP."
/// The trapping and end-of-turn damage themselves are implemented by the <see cref="FireSpinEffect"/>
/// volatile; the move script is responsible for applying that volatile to the target.
/// </summary>
public class FireSpinTests
{
/// <summary>
/// Creates a fully mocked test setup for Fire Spin tests. The target gets a real
/// <see cref="ScriptSet"/> as its volatile set, so the applied effect can be inspected.
/// The battle random is stubbed to return 5, the maximum of Fire Spin's four-to-five turn duration.
/// </summary>
private static (FireSpin script, IExecutingMove move, IPokemon target, IScriptSet targetVolatile) CreateTestSetup()
{
var script = new FireSpin();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var targetVolatile = new ScriptSet(target);
target.Volatile.Returns(targetVolatile);
var random = Substitute.For<IBattleRandom>();
random.GetInt(Arg.Any<int>(), Arg.Any<int>()).Returns(5);
var battle = Substitute.For<IBattle>();
battle.Random.Returns(random);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
target.BattleData.Returns(battleData);
return (script, move, target, targetVolatile);
}
/// <summary>
/// Bulbapedia: Fire Spin traps the target and deals "1/8 of the target's maximum HP" at the end of each
/// turn. The move script applies the <see cref="FireSpinEffect"/> volatile, which implements the trap,
/// to the target that was hit.
/// </summary>
[Test]
public async Task OnSecondaryEffect_MoveHits_AddsFireSpinEffectToTargetVolatile()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Get<FireSpinEffect>()).IsNotNull();
}
/// <summary>
/// Technical test: hitting a target that is already trapped by Fire Spin does not add a second
/// <see cref="FireSpinEffect"/>; the existing volatile is reused.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetAlreadyTrapped_DoesNotAddSecondEffect()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Count).IsEqualTo(1);
}
// ----- FireSpinEffect behavior (the volatile the move applies) -----
/// <summary>
/// Creates a trapped Pokémon substitute with the given maximum HP, together with the
/// <see cref="FireSpinEffect"/> that traps it.
/// </summary>
private static (FireSpinEffect effect, IPokemon owner) CreateTrappedPokemon(uint maxHealth, IPokemon? user = null)
{
var owner = Substitute.For<IPokemon>();
owner.BoostedStats.Returns(new StatisticSet<uint>(maxHealth, 1, 1, 1, 1, 1));
owner.MaxHealth.Returns(maxHealth);
owner.Types.Returns(new List<TypeIdentifier> { new(10, "fire") });
return (new FireSpinEffect(owner, 5, user ?? Substitute.For<IPokemon>()), owner);
}
/// <summary>
/// Helper to extract the damage amount from a Pokémon's received Damage calls.
/// </summary>
private static uint? GetDamageAmount(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "The end turn damage of Fire Spin is increased from 1/16 to 1/8
/// of the target's maximum HP."
/// </summary>
[Test, Arguments(96u, 12u), Arguments(100u, 12u), Arguments(120u, 15u), Arguments(8u, 1u)]
public async Task OnEndTurn_TrappedPokemon_TakesOneEighthOfMaxHpAsDamage(uint maxHealth, uint expectedDamage)
{
// Arrange
var (effect, owner) = CreateTrappedPokemon(maxHealth);
// Act
effect.OnEndTurn(owner, Substitute.For<IBattle>());
// Assert
await Assert.That(GetDamageAmount(owner)!.Value).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: the trap deals "end turn damage"; this is indirect damage, not move damage, so it uses
/// <see cref="DamageSource.Misc"/>.
/// </summary>
[Test]
public async Task OnEndTurn_TrappedPokemon_DamageIsIndirect()
{
// Arrange
var (effect, owner) = CreateTrappedPokemon(100);
// Act
effect.OnEndTurn(owner, Substitute.For<IBattle>());
// Assert
var call = owner.ReceivedCalls().First(c => c.GetMethodInfo().Name == "Damage");
await Assert.That((DamageSource)call.GetArguments()[1]!).IsEqualTo(DamageSource.Misc);
}
/// <summary>
/// Bulbapedia (Generation II onwards): Fire Spin "trapped the opponent, preventing switching."
/// </summary>
[Test]
public async Task PreventSelfSwitch_TrappedPokemon_CannotSwitchOut()
{
// Arrange
var (effect, owner) = CreateTrappedPokemon(100);
var choice = Substitute.For<ISwitchChoice>();
choice.User.Returns(owner);
var prevent = false;
// Act
effect.PreventSelfSwitch(choice, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: Fire Spin traps the target; a trapped wild Pokémon cannot flee (the Generation III-IV
/// text lists Run Away and a held Smoke Ball as the only ways a wild Pokémon can still escape).
/// </summary>
[Test]
public async Task PreventSelfRunAway_TrappedPokemon_CannotFlee()
{
// Arrange
var (effect, owner) = CreateTrappedPokemon(100);
var choice = Substitute.For<IFleeChoice>();
choice.User.Returns(owner);
var prevent = false;
// Act
effect.PreventSelfRunAway(choice, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "Ghost-type Pokémon cannot be trapped by Fire Spin." A trapped
/// Ghost-type Pokémon must remain free to switch out.
/// </summary>
[Test]
public async Task PreventSelfSwitch_GhostTypeOwner_CanStillSwitchOut()
{
// Arrange
var (effect, owner) = CreateTrappedPokemon(100);
owner.Types.Returns(new List<TypeIdentifier> { new(8, "ghost") });
var choice = Substitute.For<ISwitchChoice>();
choice.User.Returns(owner);
var prevent = false;
// Act
effect.PreventSelfSwitch(choice, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "If the user is holding a Binding Band, the end turn damage of
/// Fire Spin will increase to 1/6 of the target's maximum HP."
/// </summary>
[Test]
public async Task OnEndTurn_UserHoldingBindingBand_DamageIsOneSixthOfMaxHp()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
var user = Substitute.For<IPokemon>();
user.HasHeldItem("binding_band").Returns(true);
move.User.Returns(user);
target.BoostedStats.Returns(new StatisticSet<uint>(120, 1, 1, 1, 1, 1));
target.MaxHealth.Returns(120u);
// Act
script.OnSecondaryEffect(move, target, 0);
var effect = targetVolatile.Get<FireSpinEffect>();
effect!.OnEndTurn(target, Substitute.For<IBattle>());
// Assert - 1/6 of 120 max HP
await Assert.That(GetDamageAmount(target)!.Value).IsEqualTo(20u);
}
/// <summary>
/// Bulbapedia (Generation V onwards): "it now lasts for four to five turns." After at most five
/// end-of-turn ticks the trap must have removed itself from the target.
/// </summary>
[Test]
public async Task OnEndTurn_AfterFiveEndOfTurns_TrapHasEnded()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
target.BoostedStats.Returns(new StatisticSet<uint>(80, 1, 1, 1, 1, 1));
script.OnSecondaryEffect(move, target, 0);
var effect = targetVolatile.Get<FireSpinEffect>()!;
var battle = Substitute.For<IBattle>();
// Act - five end-of-turn ticks, the maximum duration of the trap
for (var i = 0; i < 5; i++)
effect.OnEndTurn(target, battle);
// Assert
await Assert.That(targetVolatile.Contains("fire_spin")).IsFalse();
}
}

View File

@@ -0,0 +1,113 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FirstImpression"/> move script.
/// Gen VII Bulbapedia behavior: "First Impression inflicts damage. It has a priority of +2, and is used
/// before moves of lower priority. First Impression always fails if it is used after the first turn the
/// user is out, or if the move is called by Instruct."
/// </summary>
public class FirstImpressionTests
{
/// <summary>
/// Creates a fully mocked test setup for First Impression tests, with the user having switched in on
/// <paramref name="switchInTurn"/> while the battle is on <paramref name="currentTurn"/>.
/// </summary>
private static (FirstImpression script, IExecutingMove move, IPokemon user) CreateTestSetup(uint switchInTurn,
uint currentTurn)
{
var script = new FirstImpression();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
var battle = Substitute.For<IBattle>();
battle.CurrentTurnNumber.Returns(currentTurn);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
battleData.SwitchInTurn.Returns(switchInTurn);
user.BattleData.Returns(battleData);
move.User.Returns(user);
return (script, move, user);
}
/// <summary>
/// Bulbapedia: "First Impression always fails if it is used after the first turn the user is out".
/// On the turn the user switched in, the move is allowed to execute.
/// </summary>
[Test]
public async Task StopBeforeMove_UsedOnSwitchInTurn_MoveIsNotStopped()
{
// Arrange
var (script, move, _) = CreateTestSetup(3, 3);
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
}
/// <summary>
/// Bulbapedia: "First Impression always fails if it is used after the first turn the user is out".
/// One turn after switching in, the move is stopped.
/// </summary>
[Test]
public async Task StopBeforeMove_UsedTurnAfterSwitchIn_MoveIsStopped()
{
// Arrange
var (script, move, _) = CreateTestSetup(1, 2);
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsTrue();
}
/// <summary>
/// Bulbapedia: "First Impression always fails if it is used after the first turn the user is out".
/// The move only executes when the current turn is the turn the user switched in, regardless of
/// the absolute turn numbers.
/// </summary>
[Test, Arguments(1u, 1u, false), Arguments(1u, 2u, true), Arguments(4u, 4u, false), Arguments(2u, 7u, true)]
public async Task StopBeforeMove_SwitchInTurnVersusCurrentTurn_StopsOnlyAfterFirstTurnOut(uint switchInTurn,
uint currentTurn, bool expectedStop)
{
// Arrange
var (script, move, _) = CreateTestSetup(switchInTurn, currentTurn);
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsEqualTo(expectedStop);
}
/// <summary>
/// Technical test: if the user has no <see cref="IPokemon.BattleData"/>, the script cannot determine the
/// switch-in turn and leaves the move untouched.
/// </summary>
[Test]
public async Task StopBeforeMove_NoBattleData_MoveIsNotStopped()
{
// Arrange
var script = new FirstImpression();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
move.User.Returns(user);
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
}
}

View File

@@ -0,0 +1,113 @@
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="Flail"/> move script.
/// Gen VII Bulbapedia behavior: "Flail inflicts damage and has no additional effect. Its power is based on
/// the user's current HP as a percentage of its maximum HP, which is higher the lower the user's HP is."
/// The power tiers (all generations except IV) are: 20 power at HP ≥ 68.8%, 40 at 35.4% ≤ HP &lt; 68.8%,
/// 80 at 20.8% ≤ HP &lt; 35.4%, 100 at 10.4% ≤ HP &lt; 20.8%, 150 at 4.2% ≤ HP &lt; 10.4%, and
/// 200 at HP &lt; 4.2%. These percentages correspond to the tiers of floor(48 × current HP / max HP).
/// </summary>
public class FlailTests
{
/// <summary>
/// Creates a fully mocked test setup for Flail tests with the user at the given current and maximum HP.
/// </summary>
private static (Flail script, IExecutingMove move, IPokemon target) CreateTestSetup(uint currentHp, uint maxHp)
{
var script = new Flail();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var user = Substitute.For<IPokemon>();
user.CurrentHealth.Returns(currentHp);
user.BoostedStats.Returns(new StatisticSet<uint>(maxHp, 0, 0, 0, 0, 0));
move.User.Returns(user);
return (script, move, target);
}
/// <summary>
/// Bulbapedia: Flail's power is 20 when the user's HP is at least 68.8% of its maximum.
/// A user at full health uses the weakest power tier.
/// </summary>
[Test]
public async Task ChangeBasePower_UserAtFullHealth_PowerIs20()
{
// Arrange
var (script, move, target) = CreateTestSetup(100, 100);
ushort basePower = 20;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)20);
}
/// <summary>
/// Bulbapedia: Flail's power is 200 when the user's HP is below 4.2% of its maximum.
/// A user at 1 HP uses the strongest power tier.
/// </summary>
[Test]
public async Task ChangeBasePower_UserAtOneHp_PowerIs200()
{
// Arrange
var (script, move, target) = CreateTestSetup(1, 100);
ushort basePower = 20;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)200);
}
/// <summary>
/// Bulbapedia: "Its power is based on the user's current HP as a percentage of its maximum HP, which is
/// higher the lower the user's HP is". These rows cover the extremes of the table: full health
/// (HP ≥ 68.8% → 20 power) and nearly fainted (HP &lt; 4.2% → 200 power).
/// </summary>
[Test, Arguments(100u, 100u, 20), Arguments(4u, 100u, 200), Arguments(1u, 100u, 200)]
// 48/48 -> 20
// floor(1.92) = 1 -> 200
// floor(0.48) = 0 -> 200
public async Task ChangeBasePower_HpAtExtremes_MatchesFlailPowerTable(uint currentHp, uint maxHp, int expectedPower)
{
// Arrange
var (script, move, target) = CreateTestSetup(currentHp, maxHp);
ushort basePower = 20;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)expectedPower);
}
/// <summary>
/// Bulbapedia: "Its power is based on the user's current HP as a percentage of its maximum HP, which is
/// higher the lower the user's HP is", following the tiers 20 (HP ≥ 68.8%), 40 (35.4% ≤ HP &lt; 68.8%),
/// 80 (20.8% ≤ HP &lt; 35.4%), 100 (10.4% ≤ HP &lt; 20.8%), 150 (4.2% ≤ HP &lt; 10.4%) and 200 (HP &lt; 4.2%).
/// The rows cover both boundary values of every tier, verifying the integer arithmetic of
/// floor(48 × current HP / max HP).
/// </summary>
[Test, Arguments(69u, 100u, 20), Arguments(68u, 100u, 40), Arguments(36u, 100u, 40), Arguments(35u, 100u, 80),
Arguments(21u, 100u, 80), Arguments(20u, 100u, 100), Arguments(11u, 100u, 100), Arguments(10u, 100u, 150),
Arguments(5u, 100u, 150), Arguments(150u, 300u, 40), Arguments(24u, 48u, 40), Arguments(7u, 48u, 100)]
public async Task ChangeBasePower_HpFractionOfMax_MatchesFlailPowerTable(uint currentHp, uint maxHp,
int expectedPower)
{
// Arrange
var (script, move, target) = CreateTestSetup(currentHp, maxHp);
ushort basePower = 20;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)expectedPower);
}
}

View File

@@ -0,0 +1,288 @@
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="FlameBurst"/> move script.
/// Gen VVII Bulbapedia behavior: "Flame Burst deals damage to the target, then inflicts damage to up to two
/// Pokémon adjacent to that target (including the user if targeting an ally) equal to 1/16 of their
/// respective maximum HP." The splash damage is classified as effect damage rather than move damage.
/// </summary>
public class FlameBurstTests
{
/// <summary>
/// Creates a mocked battle Pokémon that lives in <paramref name="battle"/> on the given side and position.
/// </summary>
private static IPokemon CreateBattlePokemon(IBattle battle, byte sideIndex, byte position, uint maxHp = 100)
{
var pokemon = Substitute.For<IPokemon>();
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
battleData.SideIndex.Returns(sideIndex);
battleData.Position.Returns(position);
pokemon.BattleData.Returns(battleData);
pokemon.BoostedStats.Returns(new StatisticSet<uint>(maxHp, 0, 0, 0, 0, 0));
pokemon.MaxHealth.Returns(maxHp);
return pokemon;
}
/// <summary>
/// Configures the two sides of <paramref name="battle"/> with the given Pokémon lists.
/// </summary>
private static void SetSides(IBattle battle, List<IPokemon?> side0Pokemon, List<IPokemon?> side1Pokemon)
{
var side0 = Substitute.For<IBattleSide>();
side0.Pokemon.Returns(side0Pokemon);
var side1 = Substitute.For<IBattleSide>();
side1.Pokemon.Returns(side1Pokemon);
battle.Sides.Returns(new[] { side0, side1 });
}
/// <summary>
/// Creates a fully mocked double battle: the user and its ally on side 0, the target and its ally on
/// side 1. Flame Burst is used by the user against the target.
/// </summary>
private static (FlameBurst script, IExecutingMove move, IPokemon user, IPokemon userAlly, IPokemon target, IPokemon
targetAlly) CreateDoubleBattleSetup(uint targetAllyMaxHp = 160)
{
var script = new FlameBurst();
var battle = Substitute.For<IBattle>();
battle.PositionsPerSide.Returns((byte)2);
var user = CreateBattlePokemon(battle, 0, 0);
var userAlly = CreateBattlePokemon(battle, 0, 1);
var target = CreateBattlePokemon(battle, 1, 0);
var targetAlly = CreateBattlePokemon(battle, 1, 1, targetAllyMaxHp);
SetSides(battle, [user, userAlly], [target, targetAlly]);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
return (script, move, user, userAlly, target, targetAlly);
}
/// <summary>
/// Helper to extract the damage amount from a Pokémon's received Damage calls, or 0 if none was received.
/// </summary>
private static uint GetDamageAmount(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (uint)call.GetArguments()[0]! : 0;
}
/// <summary>
/// Helper to extract the damage source from a Pokémon's received Damage calls.
/// </summary>
private static DamageSource? GetDamageSource(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (DamageSource)call.GetArguments()[1]! : null;
}
/// <summary>
/// Helper that checks whether a Pokémon received any Damage call.
/// </summary>
private static bool WasDamaged(IPokemon pokemon) =>
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage");
/// <summary>
/// Bulbapedia: "Flame Burst deals damage to the target, then inflicts damage to up to two Pokémon adjacent
/// to that target ... equal to 1/16 of their respective maximum HP."
/// In a double battle, the target's ally is adjacent to the target and takes 1/16 of its own maximum HP.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DoubleBattle_TargetAllyTakesOneSixteenthOfItsMaxHp()
{
// Arrange
var (script, move, _, _, target, targetAlly) = CreateDoubleBattleSetup(160);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - 160 / 16 = 10
await Assert.That(GetDamageAmount(targetAlly)).IsEqualTo(10u);
}
/// <summary>
/// Bulbapedia: the splash damage is "equal to 1/16 of their respective maximum HP".
/// Tests various maximum HP values to ensure proper integer truncation.
/// </summary>
[Test, Arguments(160u, 10u), Arguments(100u, 6u), Arguments(32u, 2u), Arguments(15u, 0u)]
public async Task OnSecondaryEffect_DoubleBattle_SplashDamageCalculation(uint allyMaxHp, uint expectedDamage)
{
// Arrange
var (script, move, _, _, target, targetAlly) = CreateDoubleBattleSetup(allyMaxHp);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageAmount(targetAlly)).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: the splash damage hits Pokémon "adjacent to that target". The user's own ally is not
/// adjacent to the target and must not be damaged when targeting an opponent.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DoubleBattle_UserAllyIsNotDamaged()
{
// Arrange
var (script, move, _, userAlly, target, _) = CreateDoubleBattleSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(WasDamaged(userAlly)).IsFalse();
}
/// <summary>
/// Bulbapedia: "this extra damage is classified as effect damage", so the splash damage is dealt with
/// <see cref="DamageSource.Misc"/> rather than move damage.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DoubleBattle_SplashDamageUsesMiscDamageSource()
{
// Arrange
var (script, move, _, _, target, targetAlly) = CreateDoubleBattleSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageSource(targetAlly)!.Value).IsEqualTo(DamageSource.Misc);
}
/// <summary>
/// Bulbapedia: "inflicts damage to up to two Pokémon adjacent to that target".
/// In a triple battle with the target in the middle, both of the target's allies are adjacent and both
/// take splash damage.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TripleBattleTargetInMiddle_BothAdjacentAlliesDamaged()
{
// Arrange
var script = new FlameBurst();
var battle = Substitute.For<IBattle>();
battle.PositionsPerSide.Returns((byte)3);
var user = CreateBattlePokemon(battle, 0, 1);
var target = CreateBattlePokemon(battle, 1, 1);
var allyLeft = CreateBattlePokemon(battle, 1, 0, 96);
var allyRight = CreateBattlePokemon(battle, 1, 2, 64);
SetSides(battle, [null, user, null], [allyLeft, target, allyRight]);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - 96 / 16 = 6 and 64 / 16 = 4
await Assert.That(GetDamageAmount(allyLeft)).IsEqualTo(6u);
await Assert.That(GetDamageAmount(allyRight)).IsEqualTo(4u);
}
/// <summary>
/// Bulbapedia: only Pokémon "adjacent to that target" take splash damage.
/// In a triple battle with the target at the edge, the ally two positions away is not adjacent and takes
/// no damage.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TripleBattleTargetAtEdge_NonAdjacentAllyNotDamaged()
{
// Arrange
var script = new FlameBurst();
var battle = Substitute.For<IBattle>();
battle.PositionsPerSide.Returns((byte)3);
var user = CreateBattlePokemon(battle, 0, 0);
var target = CreateBattlePokemon(battle, 1, 0);
var allyMiddle = CreateBattlePokemon(battle, 1, 1, 160);
var allyFar = CreateBattlePokemon(battle, 1, 2, 160);
SetSides(battle, [user, null, null], [target, allyMiddle, allyFar]);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - only the adjacent ally is hit
await Assert.That(GetDamageAmount(allyMiddle)).IsEqualTo(10u);
await Assert.That(WasDamaged(allyFar)).IsFalse();
}
/// <summary>
/// Bulbapedia: the splash damage hits Pokémon "adjacent to that target". In a single battle there are no
/// Pokémon adjacent to the target, so nothing else is damaged.
/// </summary>
[Test]
public async Task OnSecondaryEffect_SingleBattle_NoSplashDamage()
{
// Arrange
var script = new FlameBurst();
var battle = Substitute.For<IBattle>();
battle.PositionsPerSide.Returns((byte)1);
var user = CreateBattlePokemon(battle, 0, 0);
var target = CreateBattlePokemon(battle, 1, 0);
SetSides(battle, [user], [target]);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - neither the user nor the target receives effect damage
await Assert.That(WasDamaged(user)).IsFalse();
await Assert.That(WasDamaged(target)).IsFalse();
}
/// <summary>
/// Technical test: an empty adjacent position (a fainted or absent ally) does not cause an exception and
/// simply receives no splash damage.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AdjacentPositionEmpty_DoesNotThrow()
{
// Arrange
var script = new FlameBurst();
var battle = Substitute.For<IBattle>();
battle.PositionsPerSide.Returns((byte)2);
var user = CreateBattlePokemon(battle, 0, 0);
var userAlly = CreateBattlePokemon(battle, 0, 1);
var target = CreateBattlePokemon(battle, 1, 0);
SetSides(battle, [user, userAlly], [target, null]);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
// Act & Assert
await Assert.That(() => script.OnSecondaryEffect(move, target, 0)).ThrowsNothing();
}
/// <summary>
/// Technical test: when no battle data is available the script does nothing instead of throwing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NoBattleData_DoesNotThrow()
{
// Arrange
var script = new FlameBurst();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns((IPokemonBattleData?)null);
// Act & Assert
await Assert.That(() => script.OnSecondaryEffect(move, target, 0)).ThrowsNothing();
}
}

View File

@@ -0,0 +1,185 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FlameWheel"/> move script.
/// Gen VII Bulbapedia behavior: "Flame Wheel will thaw out the user if it is frozen, and then inflict damage
/// on the target. Flame Wheel also has a 10% chance of burning the target."
/// </summary>
public class FlameWheelTests
{
/// <summary>
/// Creates a fully mocked test setup for Flame Wheel tests, initialized with the given burn chance.
/// </summary>
private static (FlameWheel script, IExecutingMove move, IPokemon user, IPokemon target, IBattleRandom random)
CreateTestSetup(float burnChance = 10f)
{
var script = new FlameWheel();
script.OnInitialize(new Dictionary<StringKey, object?> { { "burn_chance", burnChance } });
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
var target = Substitute.For<IPokemon>();
var battle = Substitute.For<IBattle>();
var random = Substitute.For<IBattleRandom>();
battle.Random.Returns(random);
move.Battle.Returns(battle);
move.User.Returns(user);
return (script, move, user, target, random);
}
/// <summary>
/// Helper that checks whether a Pokémon received a SetStatus call for the given status.
/// </summary>
private static bool ReceivedSetStatus(IPokemon pokemon, string status) =>
pokemon.ReceivedCalls().Any(c =>
c.GetMethodInfo().Name == "SetStatus" && (StringKey)c.GetArguments()[0]! == new StringKey(status));
/// <summary>
/// Helper that checks whether a Pokémon received a ClearStatus call.
/// </summary>
private static bool ReceivedClearStatus(IPokemon pokemon) =>
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ClearStatus");
/// <summary>
/// Bulbapedia: "Flame Wheel also has a 10% chance of burning the target."
/// When the effect chance roll succeeds, the target is burned by the user.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EffectChanceSucceeds_BurnsTarget()
{
// Arrange
var (script, move, _, target, random) = CreateTestSetup();
random.EffectChance(10f, move, target, 0).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedSetStatus(target, "burned")).IsTrue();
}
/// <summary>
/// Bulbapedia: "Flame Wheel also has a 10% chance of burning the target."
/// When the effect chance roll fails, the target is not burned.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EffectChanceFails_DoesNotBurnTarget()
{
// Arrange
var (script, move, _, target, random) = CreateTestSetup();
random.EffectChance(10f, move, target, 0).Returns(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedSetStatus(target, "burned")).IsFalse();
}
/// <summary>
/// Bulbapedia: "Flame Wheel also has a 10% chance of burning the target."
/// The configured burn chance is the one rolled against, and it is rolled through the battle's
/// <see cref="IBattleRandom.EffectChance"/> so that effect-chance-modifying effects apply.
/// </summary>
[Test]
public async Task OnSecondaryEffect_ConfiguredBurnChance_IsPassedToEffectChanceRoll()
{
// Arrange
var (script, move, _, target, random) = CreateTestSetup(30f);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
random.Received(1).EffectChance(30f, move, target, 0);
}
/// <summary>
/// Bulbapedia: "Flame Wheel will thaw out the user if it is frozen".
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserFrozen_UserIsThawed()
{
// Arrange
var (script, move, user, target, _) = CreateTestSetup();
user.HasStatus("frozen").Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedClearStatus(user)).IsTrue();
}
/// <summary>
/// Bulbapedia: "Flame Wheel will thaw out the user if it is frozen". A user that is not frozen keeps
/// whatever status it has; the script must not clear it.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserNotFrozen_StatusIsNotCleared()
{
// Arrange
var (script, move, user, target, _) = CreateTestSetup();
user.HasStatus("frozen").Returns(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedClearStatus(user)).IsFalse();
}
/// <summary>
/// Technical test: initializing without parameters is invalid, as the script requires a burn chance.
/// </summary>
[Test]
public async Task OnInitialize_NullParameters_Throws()
{
// Arrange
var script = new FlameWheel();
// Act & Assert
await Assert.That(() => script.OnInitialize(null)).ThrowsExactly<Exception>();
}
/// <summary>
/// Technical test: initializing without a "burn_chance" parameter is invalid.
/// </summary>
[Test]
public async Task OnInitialize_MissingBurnChance_Throws()
{
// Arrange
var script = new FlameWheel();
var parameters = new Dictionary<StringKey, object?> { { "unrelated", 10f } };
// Act & Assert
await Assert.That(() => script.OnInitialize(parameters)).ThrowsExactly<Exception>();
}
/// <summary>
/// Bulbapedia: "Flame Wheel also has a 10% chance of burning the target."
/// Integration check: initializing the script with Flame Wheel's actual effect parameters from the Gen7
/// data results in a 10% burn chance being rolled.
/// </summary>
[Test]
public async Task OnSecondaryEffect_InitializedWithFlameWheelData_UsesTenPercentBurnChance()
{
// Arrange
var library = LibraryHelpers.LoadLibrary();
await Assert.That(library.StaticLibrary.Moves.TryGet("flame_wheel", out var flameWheel)).IsTrue();
var (script, move, _, target, random) = CreateTestSetup();
// Act
script.OnInitialize(flameWheel!.SecondaryEffect!.Parameters);
script.OnSecondaryEffect(move, target, 0);
// Assert
random.Received(1).EffectChance(10f, move, target, 0);
}
}

View File

@@ -0,0 +1,234 @@
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="FlareBlitz"/> move script.
/// Gen VII Bulbapedia behavior: "Flare Blitz inflicts damage, and the user receives recoil damage equal to
/// ⅓ of the damage done to the target. This move has a 10% chance of burning the target."
/// </summary>
public class FlareBlitzTests
{
/// <summary>
/// Creates a fully mocked test setup for Flare Blitz tests, where the hit dealt
/// <paramref name="damage"/> damage to the target.
/// </summary>
private static (FlareBlitz script, IExecutingMove move, IPokemon user, IPokemon target, IBattleRandom random)
CreateTestSetup(uint damage, Script[]? moveScripts = null)
{
var script = new FlareBlitz();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
hitData.Damage.Returns(damage);
move.GetHitData(target, 0).Returns(hitData);
var battle = Substitute.For<IBattle>();
var random = Substitute.For<IBattleRandom>();
battle.Random.Returns(random);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
target.BattleData.Returns(battleData);
// RunScriptHook iterates the move's scripts; give the mock a real iterator so the hook pass runs
// (empty unless the test attaches scripts such as Rock Head).
var containers = (moveScripts ?? []).Select(IEnumerable<ScriptContainer> (s) => new ScriptContainer(s))
.ToArray();
move.GetScripts().Returns(_ => new ScriptIterator(containers));
move.User.Returns(user);
return (script, move, user, target, random);
}
/// <summary>
/// Helper to extract the damage amount from a Pokémon's received Damage calls, or 0 if none was received.
/// </summary>
private static uint GetDamageAmount(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (uint)call.GetArguments()[0]! : 0;
}
/// <summary>
/// Helper to extract the damage source from a Pokémon's received Damage calls.
/// </summary>
private static DamageSource? GetDamageSource(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (DamageSource)call.GetArguments()[1]! : null;
}
/// <summary>
/// Helper that checks whether a Pokémon received a SetStatus call for the given status.
/// </summary>
private static bool ReceivedSetStatus(IPokemon pokemon, string status) =>
pokemon.ReceivedCalls().Any(c =>
c.GetMethodInfo().Name == "SetStatus" && (StringKey)c.GetArguments()[0]! == new StringKey(status));
/// <summary>
/// Bulbapedia: "the user receives recoil damage equal to ⅓ of the damage done to the target."
/// </summary>
[Test]
public async Task OnSecondaryEffect_DamageDealt_UserTakesOneThirdRecoil()
{
// Arrange
var (script, move, user, target, _) = CreateTestSetup(90);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - 90 / 3 = 30
await Assert.That(GetDamageAmount(user)).IsEqualTo(30u);
}
/// <summary>
/// Bulbapedia: "the user receives recoil damage equal to ⅓ of the damage done to the target."
/// Tests various damage values to ensure proper integer truncation of the one-third amount.
/// </summary>
[Test, Arguments(90u, 30u), Arguments(100u, 33u), Arguments(120u, 40u), Arguments(2u, 0u), Arguments(301u, 100u)]
public async Task OnSecondaryEffect_DamageDealt_RecoilCalculation(uint damage, uint expectedRecoil)
{
// Arrange
var (script, move, user, target, _) = CreateTestSetup(damage);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageAmount(user)).IsEqualTo(expectedRecoil);
}
/// <summary>
/// Bulbapedia: "the user receives recoil damage". Recoil is indirect damage, so it is dealt with
/// <see cref="DamageSource.Misc"/> rather than move damage.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DamageDealt_RecoilUsesMiscDamageSource()
{
// Arrange
var (script, move, user, target, _) = CreateTestSetup(90);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageSource(user)!.Value).IsEqualTo(DamageSource.Misc);
}
/// <summary>
/// Bulbapedia: "This move has a 10% chance of burning the target."
/// When the effect chance roll succeeds, the target is burned.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EffectChanceSucceeds_BurnsTarget()
{
// Arrange
var (script, move, _, target, random) = CreateTestSetup(90);
random.EffectChance(10f, move, target, 0).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedSetStatus(target, "burned")).IsTrue();
}
/// <summary>
/// Bulbapedia: "This move has a 10% chance of burning the target."
/// When the effect chance roll fails, the target is not burned.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EffectChanceFails_DoesNotBurnTarget()
{
// Arrange
var (script, move, _, target, random) = CreateTestSetup(90);
random.EffectChance(10f, move, target, 0).Returns(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedSetStatus(target, "burned")).IsFalse();
}
/// <summary>
/// Bulbapedia: "This move has a 10% chance of burning the target."
/// The 10% chance is rolled through the battle's <see cref="IBattleRandom.EffectChance"/> so that
/// effect-chance-modifying effects (such as Serene Grace) apply.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DamageDealt_RollsTenPercentEffectChance()
{
// Arrange
var (script, move, _, target, random) = CreateTestSetup(90);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
random.Received(1).EffectChance(10f, move, target, 0);
}
/// <summary>
/// The recoil can be prevented by recoil-negating effects (Bulbapedia: Rock Head "prevents the Pokémon
/// from taking recoil damage from most moves"). When a script such as <see cref="RockHead"/> flags the
/// recoil as prevented, the user takes no recoil damage.
/// </summary>
[Test]
public async Task OnSecondaryEffect_RecoilPrevented_UserTakesNoRecoilDamage()
{
// Arrange
var (script, move, user, target, _) = CreateTestSetup(90, [new RockHead()]);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse();
}
/// <summary>
/// Bulbapedia: "This move has a 10% chance of burning the target." The burn chance is independent of the
/// recoil: Rock Head only "prevents the Pokémon from taking recoil damage", so preventing the recoil must
/// not suppress the burn chance.
/// </summary>
[Test]
public async Task OnSecondaryEffect_RecoilPrevented_BurnChanceStillApplies()
{
// Arrange
var (script, move, _, target, random) = CreateTestSetup(90, [new RockHead()]);
random.EffectChance(10f, move, target, 0).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedSetStatus(target, "burned")).IsTrue();
}
/// <summary>
/// Technical test: when the target has no <see cref="IPokemon.BattleData"/>, the script does nothing;
/// no recoil is taken and no burn is rolled.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHasNoBattleData_NoRecoilOrBurn()
{
// Arrange
var (script, move, user, target, _) = CreateTestSetup(90);
target.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse();
await Assert.That(ReceivedSetStatus(target, "burned")).IsFalse();
}
}

View File

@@ -0,0 +1,142 @@
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;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Flatter"/> move script.
/// Gen VII Bulbapedia behavior: Flatter raises the target's Special Attack stat by one stage and confuses it.
/// </summary>
public class FlatterTests
{
private static (Flatter script, IExecutingMove move, IPokemon target, IScriptSet targetVolatile) CreateTestSetup()
{
var script = new Flatter();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
var targetVolatile = Substitute.For<IScriptSet>();
target.Volatile.Returns(targetVolatile);
return (script, move, target, targetVolatile);
}
/// <summary>
/// Helper to extract the arguments of the received ChangeStatBoost call, or null when no stat boost
/// was requested. Received-call inspection is used instead of NSubstitute argument matchers because
/// the trailing <see cref="EventBatchId"/> parameter cannot be bound by <c>Arg.Any</c> (its
/// parameterless constructor initializes a fresh id, so it never equals the matcher's default value).
/// </summary>
private static object?[]? GetStatBoostArgs(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
return call?.GetArguments();
}
/// <summary>
/// Bulbapedia: Flatter "raises the target's Special Attack stat by one stage".
/// The boost is caused by the opponent's move, so it is not self-inflicted.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_RaisesTargetSpecialAttackByOneStage()
{
// Arrange
var (script, move, target, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var args = GetStatBoostArgs(target);
await Assert.That(args).IsNotNull();
await Assert.That((Statistic)args![0]!).IsEqualTo(Statistic.SpecialAttack);
await Assert.That((sbyte)args[1]!).IsEqualTo((sbyte)1);
await Assert.That((bool)args[2]!).IsFalse(); // not self-inflicted
await Assert.That((bool)args[3]!).IsFalse(); // not forced
}
/// <summary>
/// Bulbapedia: Flatter raises the target's Special Attack "and confuses it". The confusion is applied
/// through the target's <see cref="IPokemon.Volatile"/> script set.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_ConfusesTarget()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
targetVolatile.Received(1).StackOrAdd(new StringKey("confusion"), Arg.Any<Func<Script?>>());
await Assert.That(targetVolatile.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "StackOrAdd")).IsTrue();
}
/// <summary>
/// Bulbapedia: Flatter "confuses" the target. The instantiation function passed to the volatile script
/// set creates a <see cref="Confusion"/> script.
/// </summary>
[Test]
public async Task OnSecondaryEffect_ConfusionInstantiation_CreatesConfusionScript()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var call = targetVolatile.ReceivedCalls().First(c => c.GetMethodInfo().Name == "StackOrAdd");
var instantiation = (Func<Script?>)call.GetArguments()[1]!;
await Assert.That(instantiation() is Confusion).IsTrue();
}
/// <summary>
/// Bulbapedia: "Even if the target's Special Attack is already at +6 stages, it will still become
/// confused." A substitute's <see cref="IPokemon.ChangeStatBoost"/> returns false by default (the
/// boost failed), yet the confusion is still applied.
/// </summary>
[Test]
public async Task OnSecondaryEffect_StatBoostFails_TargetStillConfused()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - the unconfigured ChangeStatBoost returned false, but the target was still confused
await Assert.That(GetStatBoostArgs(target)).IsNotNull();
await Assert.That(targetVolatile.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "StackOrAdd")).IsTrue();
}
/// <summary>
/// Bulbapedia: "Even if the target is already confused, or cannot become confused due to Safeguard or
/// Own Tempo, its Special Attack will still be raised." When the volatile set refuses to add the
/// confusion script (returns null), the Special Attack boost still happens.
/// </summary>
[Test]
public async Task OnSecondaryEffect_ConfusionNotAdded_SpecialAttackStillRaised()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
targetVolatile.StackOrAdd(Arg.Any<StringKey>(), Arg.Any<Func<Script?>>()).Returns((ScriptContainer?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var args = GetStatBoostArgs(target);
await Assert.That(args).IsNotNull();
await Assert.That((Statistic)args![0]!).IsEqualTo(Statistic.SpecialAttack);
await Assert.That((sbyte)args[1]!).IsEqualTo((sbyte)1);
}
}

View File

@@ -0,0 +1,142 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Fling"/> move script.
/// Gen VII Bulbapedia behavior: Fling inflicts damage; the power of the move is determined by the user's
/// held item, the move fails when there is no (flingable) held item, and the item is consumed afterwards.
/// </summary>
public class FlingTests
{
private static (Fling script, IExecutingMove move, IPokemon target, IPokemon user, IHitData hitData)
CreateTestSetup(IItem? heldItem)
{
var script = new Fling();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
var user = Substitute.For<IPokemon>();
user.HeldItem.Returns(heldItem);
move.User.Returns(user);
return (script, move, target, user, hitData);
}
private static IItem CreateItem(ItemCategory category, byte? flingPower)
{
var item = Substitute.For<IItem>();
item.Category.Returns(category);
item.TryGetAdditionalData<byte>(new StringKey("flingPower"), out Arg.Any<byte>()).Returns(x =>
{
x[1] = flingPower ?? default(byte);
return flingPower.HasValue;
});
return item;
}
/// <summary>
/// Bulbapedia: "Fling inflicts damage; the power of the move is determined by the user's held item."
/// The base power is replaced by the item's fling power (e.g. 10 for Berries, 130 for an Iron Ball).
/// </summary>
[Test, Arguments((byte)10), Arguments((byte)30), Arguments((byte)80), Arguments((byte)130)]
public async Task ChangeBasePower_ItemWithFlingPower_BasePowerSetToItemFlingPower(byte flingPower)
{
// Arrange
var item = CreateItem(ItemCategory.MiscItem, flingPower);
var (script, move, target, _, hitData) = CreateTestSetup(item);
ushort basePower = 1;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)flingPower);
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsFalse();
}
/// <summary>
/// Bulbapedia: "The move fails if the user lacks a held item". Without a held item, the hit is failed
/// and the base power is left untouched.
/// </summary>
[Test]
public async Task ChangeBasePower_NoHeldItem_MoveFails()
{
// Arrange
var (script, move, target, _, hitData) = CreateTestSetup(null);
ushort basePower = 1;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
hitData.Received(1).Fail();
await Assert.That(basePower).IsEqualTo((ushort)1);
}
/// <summary>
/// Bulbapedia: the move fails if the user carries specific unflingable items, including "TMs, ...
/// Poké Balls, Mail, ... Z-Crystals, ... and Mega Stones" and other key items. Items in those
/// categories fail the hit even when they define a fling power.
/// </summary>
[Test, Arguments(ItemCategory.Pokeball), Arguments(ItemCategory.Mail), Arguments(ItemCategory.KeyItem),
Arguments(ItemCategory.TmHm), Arguments(ItemCategory.FormChanger)]
public async Task ChangeBasePower_UnflingableItemCategory_MoveFails(ItemCategory category)
{
// Arrange
var item = CreateItem(category, 30);
var (script, move, target, _, hitData) = CreateTestSetup(item);
ushort basePower = 1;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
hitData.Received(1).Fail();
await Assert.That(basePower).IsEqualTo((ushort)1);
}
/// <summary>
/// Bulbapedia: the power of Fling "is determined by the user's held item"; items without a defined
/// fling power cannot be flung, so the hit fails.
/// </summary>
[Test]
public async Task ChangeBasePower_ItemWithoutFlingPower_MoveFails()
{
// Arrange
var item = CreateItem(ItemCategory.MiscItem, null);
var (script, move, target, _, hitData) = CreateTestSetup(item);
ushort basePower = 1;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
hitData.Received(1).Fail();
await Assert.That(basePower).IsEqualTo((ushort)1);
}
/// <summary>
/// Bulbapedia: "After using Fling, the item is consumed". The user's held item is removed for the
/// remainder of the battle via <see cref="IPokemon.RemoveHeldItemForBattle"/>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_RemovesHeldItemForBattle()
{
// Arrange
var item = CreateItem(ItemCategory.MiscItem, 30);
var (script, move, target, user, _) = CreateTestSetup(item);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
user.Received(1).RemoveHeldItemForBattle();
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "RemoveHeldItemForBattle")).IsTrue();
}
}

View File

@@ -0,0 +1,150 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FloralHealing"/> move script.
/// Gen VII Bulbapedia behavior: Floral Healing restores up to half of the target's maximum HP, or up to
/// two thirds when Grassy Terrain is in effect.
/// </summary>
public class FloralHealingTests
{
private static (FloralHealing script, IExecutingMove move, IPokemon target) CreateTestSetup(uint maxHp,
string? terrainName = null, bool isFainted = false, bool hasBattleData = true)
{
var script = new FloralHealing();
var move = Substitute.For<IExecutingMove>();
var battle = Substitute.For<IBattle>();
battle.TerrainName.Returns(terrainName == null ? null : new StringKey?(new StringKey(terrainName)));
var target = Substitute.For<IPokemon>();
target.IsFainted.Returns(isFainted);
target.BoostedStats.Returns(new StatisticSet<uint>(maxHp, 0, 0, 0, 0, 0));
if (hasBattleData)
{
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
target.BattleData.Returns(battleData);
}
else
{
target.BattleData.Returns((IPokemonBattleData?)null);
}
return (script, move, target);
}
/// <summary>
/// Helper to extract the heal amount from the target's received Heal calls.
/// </summary>
private static uint? GetHealAmount(IPokemon target)
{
var call = target.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Heal");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Bulbapedia: "Floral Healing restores up to ½ of the target's maximum HP."
/// </summary>
[Test]
public async Task OnSecondaryEffect_NoTerrain_HealsHalfOfMaximumHp()
{
// Arrange
var (script, move, target) = CreateTestSetup(100);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetHealAmount(target)!.Value).IsEqualTo(50u);
}
/// <summary>
/// Bulbapedia: "Floral Healing restores up to ½ of the target's maximum HP."
/// Various maximum HP values verify the fractional amount is truncated to a whole number.
/// </summary>
[Test, Arguments(200u, 100u), Arguments(101u, 50u), Arguments(99u, 49u), Arguments(3u, 1u)]
public async Task OnSecondaryEffect_NoTerrain_HealCalculation(uint maxHp, uint expectedHeal)
{
// Arrange
var (script, move, target) = CreateTestSetup(maxHp);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetHealAmount(target)!.Value).IsEqualTo(expectedHeal);
}
/// <summary>
/// Bulbapedia: "If Grassy Terrain is in effect, Floral Healing will restore up to ⅔ of the target's
/// maximum HP."
/// </summary>
[Test, Arguments(99u, 66u), Arguments(100u, 66u), Arguments(3u, 2u)]
public async Task OnSecondaryEffect_GrassyTerrain_HealsTwoThirdsOfMaximumHp(uint maxHp, uint expectedHeal)
{
// Arrange
var (script, move, target) = CreateTestSetup(maxHp, "grassy_terrain");
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetHealAmount(target)!.Value).IsEqualTo(expectedHeal);
}
/// <summary>
/// Bulbapedia: only "If Grassy Terrain is in effect" is the restored amount increased to ⅔; any other
/// terrain keeps the regular ½ of maximum HP.
/// </summary>
[Test]
public async Task OnSecondaryEffect_OtherTerrain_HealsHalfOfMaximumHp()
{
// Arrange
var (script, move, target) = CreateTestSetup(100, "misty_terrain");
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetHealAmount(target)!.Value).IsEqualTo(50u);
}
/// <summary>
/// Bulbapedia: Floral Healing "restores" HP of the target; a fainted Pokémon can no longer be healed,
/// so no heal is applied when the target <see cref="IPokemon.IsFainted"/>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetFainted_DoesNotHeal()
{
// Arrange
var (script, move, target) = CreateTestSetup(100, isFainted: true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Heal")).IsFalse();
}
/// <summary>
/// Technical test: when the target has no <see cref="IPokemon.BattleData"/> (it is not in a battle),
/// the script does nothing instead of throwing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NullBattleData_DoesNotHeal()
{
// Arrange
var (script, move, target) = CreateTestSetup(100, hasBattleData: false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Heal")).IsFalse();
}
}

View File

@@ -0,0 +1,263 @@
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="FlowerShield"/> move script.
/// Gen VII Bulbapedia behavior: "Flower Shield raises the Defense stat of all Grass-type Pokémon on the
/// field by one stage, except those that are in the semi-invulnerable turn of a move such as Fly or Dig."
/// </summary>
public class FlowerShieldTests
{
private static readonly TypeIdentifier GrassType;
private static readonly TypeIdentifier WaterType;
static FlowerShieldTests()
{
var library = LibraryHelpers.LoadLibrary();
library.StaticLibrary.Types.TryGetTypeIdentifier("grass", out GrassType);
library.StaticLibrary.Types.TryGetTypeIdentifier("water", out WaterType);
}
private static (FlowerShield script, IExecutingMove move, IPokemon target, IBattle battle) CreateTestSetup(
params IReadOnlyList<IPokemon?>[] sidePokemon)
{
var script = new FlowerShield();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var library = LibraryHelpers.LoadLibrary();
var battle = Substitute.For<IBattle>();
battle.Library.Returns(library);
var sides = sidePokemon.Select(pokemon =>
{
var side = Substitute.For<IBattleSide>();
side.Pokemon.Returns(pokemon);
return side;
}).ToArray();
battle.Sides.Returns(sides);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
return (script, move, target, battle);
}
private static IPokemon CreatePokemon(TypeIdentifier type, bool isFainted = false)
{
var pokemon = Substitute.For<IPokemon>();
pokemon.Types.Returns(new[] { type });
pokemon.IsFainted.Returns(isFainted);
return pokemon;
}
private static bool ReceivedStatBoost(IPokemon pokemon) =>
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost");
/// <summary>
/// Helper to extract the arguments of the received ChangeStatBoost call, or null when no stat boost
/// was requested. Received-call inspection is used instead of NSubstitute argument matchers because
/// the trailing <see cref="EventBatchId"/> parameter cannot be bound by <c>Arg.Any</c> (its
/// parameterless constructor initializes a fresh id, so it never equals the matcher's default value).
/// </summary>
private static object?[]? GetStatBoostArgs(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
return call?.GetArguments();
}
/// <summary>
/// Bulbapedia: "Flower Shield raises the Defense stat of all Grass-type Pokémon on the field by one
/// stage". The boost on another Pokémon is not self-inflicted.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GrassTypeOnField_DefenseRaisedByOneStage()
{
// Arrange
var grassPokemon = CreatePokemon(GrassType);
var (script, move, target, _) = CreateTestSetup(new List<IPokemon?> { grassPokemon });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var args = GetStatBoostArgs(grassPokemon);
await Assert.That(args).IsNotNull();
await Assert.That((Statistic)args![0]!).IsEqualTo(Statistic.Defense);
await Assert.That((sbyte)args[1]!).IsEqualTo((sbyte)1);
await Assert.That((bool)args[2]!).IsFalse(); // not self-inflicted
await Assert.That((bool)args[3]!).IsFalse(); // not forced
}
/// <summary>
/// Bulbapedia: the Defense boost applies to "all Grass-type Pokémon on the field", so Grass-types on
/// every side (allies and opponents alike) are raised.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GrassTypesOnBothSides_AllAreRaised()
{
// Arrange
var allyGrass = CreatePokemon(GrassType);
var opposingGrass = CreatePokemon(GrassType);
var (script, move, target, _) = CreateTestSetup(new List<IPokemon?> { allyGrass },
new List<IPokemon?> { opposingGrass });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedStatBoost(allyGrass)).IsTrue();
await Assert.That(ReceivedStatBoost(opposingGrass)).IsTrue();
}
/// <summary>
/// Bulbapedia: only "Grass-type Pokémon on the field" are affected; a non-Grass-type Pokémon does not
/// have its Defense raised.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NonGrassType_DefenseNotRaised()
{
// Arrange
var waterPokemon = CreatePokemon(WaterType);
var (script, move, target, _) = CreateTestSetup(new List<IPokemon?> { waterPokemon });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedStatBoost(waterPokemon)).IsFalse();
}
/// <summary>
/// Bulbapedia: the boost applies to Pokémon "on the field"; a fainted Grass-type Pokémon is no longer
/// battling and is not affected.
/// </summary>
[Test]
public async Task OnSecondaryEffect_FaintedGrassType_DefenseNotRaised()
{
// Arrange
var faintedGrass = CreatePokemon(GrassType, true);
var (script, move, target, _) = CreateTestSetup(new List<IPokemon?> { faintedGrass });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedStatBoost(faintedGrass)).IsFalse();
}
/// <summary>
/// Bulbapedia: "Flower Shield raises the Defense stat of all Grass-type Pokémon on the field",
/// including the user itself. For the user the boost counts as self-inflicted.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserIsGrassType_BoostIsSelfInflicted()
{
// Arrange
var (script, move, target, battle) = CreateTestSetup();
var user = move.User;
user.Types.Returns(new[] { GrassType });
user.IsFainted.Returns(false);
var side = Substitute.For<IBattleSide>();
side.Pokemon.Returns(new List<IPokemon?> { user });
battle.Sides.Returns(new[] { side });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var args = GetStatBoostArgs(user);
await Assert.That(args).IsNotNull();
await Assert.That((Statistic)args![0]!).IsEqualTo(Statistic.Defense);
await Assert.That((sbyte)args[1]!).IsEqualTo((sbyte)1);
await Assert.That((bool)args[2]!).IsTrue(); // self-inflicted for the user itself
}
/// <summary>
/// Bulbapedia: Grass-type Pokémon are raised "except those that are in the semi-invulnerable turn of a
/// move such as Fly or Dig". A Grass-type currently flying (holding the <see cref="ChargeFlyEffect"/>
/// volatile) must not have its Defense raised.
/// </summary>
[Test]
public async Task OnSecondaryEffect_SemiInvulnerableGrassType_DefenseNotRaised()
{
// Arrange
var flyingGrass = CreatePokemon(GrassType);
flyingGrass.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var flyingGrassVolatile = new ScriptSet(flyingGrass);
flyingGrassVolatile.Add(new ChargeFlyEffect(flyingGrass));
flyingGrass.Volatile.Returns(flyingGrassVolatile);
var (script, move, target, _) = CreateTestSetup(new List<IPokemon?> { flyingGrass });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedStatBoost(flyingGrass)).IsFalse();
}
/// <summary>
/// Bulbapedia: "The move fails if no eligible Grass-type Pokémon are present." When no Grass-type is
/// on the field, the hit must be marked as failed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NoGrassTypesOnField_HitFails()
{
// Arrange
var waterPokemon = CreatePokemon(WaterType);
var (script, move, target, _) = CreateTestSetup(new List<IPokemon?> { waterPokemon });
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue();
}
/// <summary>
/// Technical test: empty (null) slots on a side are skipped without throwing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EmptyPokemonSlot_IsSkipped()
{
// Arrange
var grassPokemon = CreatePokemon(GrassType);
var (script, move, target, _) = CreateTestSetup(new List<IPokemon?> { null, grassPokemon });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedStatBoost(grassPokemon)).IsTrue();
}
/// <summary>
/// Technical test: when the target has no <see cref="IPokemon.BattleData"/> (it is not in a battle),
/// the script does nothing instead of throwing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NullBattleData_DoesNothing()
{
// Arrange
var grassPokemon = CreatePokemon(GrassType);
var (script, move, _, _) = CreateTestSetup(new List<IPokemon?> { grassPokemon });
var target = Substitute.For<IPokemon>();
target.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedStatBoost(grassPokemon)).IsFalse();
}
}

View File

@@ -0,0 +1,349 @@
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.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="Fly"/> move script and its <see cref="ChargeFlyEffect"/>.
/// Gen VII Bulbapedia behavior: "On the turn that Fly is selected, the user will fly up high and become
/// semi-invulnerable"; it attacks on the second turn. From Gen II onwards the user "can now be hit by Gust,
/// Thunder, Twister, and Whirlwind during the semi-invulnerable turn of Fly, and will receive double damage
/// from Gust and Twister"; from Gen V onwards it "can also be hit with Hurricane, Smack Down, and Thousand
/// Arrows".
/// </summary>
public class FlyTests
{
private static (Fly script, IExecutingMove move, IPokemon user, ScriptSet userVolatile, IMoveChoice moveChoice,
EventHook eventHook) CreateTestSetup()
{
var script = new Fly();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
// Use a real script set so the charge volatile added by Fly can be inspected afterwards.
var userVolatile = new ScriptSet(user);
user.Volatile.Returns(userVolatile);
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
move.User.Returns(user);
var moveChoice = Substitute.For<IMoveChoice>();
move.MoveChoice.Returns(moveChoice);
var eventHook = new EventHook();
var battle = Substitute.For<IBattle>();
battle.EventHook.Returns(eventHook);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
user.BattleData.Returns(battleData);
return (script, move, user, userVolatile, moveChoice, eventHook);
}
/// <summary>
/// Bulbapedia: "On the turn that Fly is selected, the user will fly up high". On the first turn the
/// move is prevented from executing.
/// </summary>
[Test]
public async Task PreventMove_FirstUse_PreventsMoveForChargeTurn()
{
// Arrange
var (script, move, _, _, _, _) = CreateTestSetup();
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: the user will "become semi-invulnerable" on the charge turn. The charge turn attaches
/// the <see cref="ChargeFlyEffect"/> volatile script to the user.
/// </summary>
[Test]
public async Task PreventMove_FirstUse_AddsChargeFlyEffectToUser()
{
// Arrange
var (script, move, _, userVolatile, _, _) = CreateTestSetup();
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<ChargeFlyEffect>())).IsTrue();
}
/// <summary>
/// The charge turn is marked on the <see cref="IMoveChoice"/> so the <see cref="ChargeFlyEffect"/>
/// knows this choice was the charging turn and does not remove itself for it.
/// </summary>
[Test]
public async Task PreventMove_FirstUse_MarksMoveChoiceAsChargeTurn()
{
// Arrange
var (script, move, _, _, moveChoice, _) = CreateTestSetup();
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
moveChoice.Received(1).SetAdditionalData(new StringKey("fly_charge"), true);
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "the user will fly up high" — the charge turn announces itself through a
/// <see cref="DialogEvent"/> so the battle log can show the fly-up message.
/// </summary>
[Test]
public async Task PreventMove_FirstUse_FiresFlyChargeDialogEvent()
{
// Arrange
var (script, move, user, _, _, eventHook) = CreateTestSetup();
DialogEvent? captured = null;
eventHook.Handler += (_, args) =>
{
if (args is DialogEvent dialogEvent)
captured = dialogEvent;
};
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(captured).IsNotNull();
await Assert.That(captured!.Message).IsEqualTo("fly_charge");
var userParameter = captured!.Parameters?.GetValueOrDefault("user");
await Assert.That(ReferenceEquals(userParameter, user)).IsTrue();
}
/// <summary>
/// Bulbapedia: Fly attacks on the second turn. When the user already has the
/// <see cref="ChargeFlyEffect"/> from the charge turn, the move is not prevented again.
/// </summary>
[Test]
public async Task PreventMove_SecondTurn_DoesNotPreventMove()
{
// Arrange
var (script, move, user, userVolatile, _, _) = CreateTestSetup();
userVolatile.Add(new ChargeFlyEffect(user));
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Technical test: a user that is not in a battle (no <see cref="IPokemon.BattleData"/>) still starts
/// the charge turn without throwing; only the dialog event is skipped.
/// </summary>
[Test]
public async Task PreventMove_NoBattleData_StillPreventsMove()
{
// Arrange
var script = new Fly();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
var userVolatile = new ScriptSet(user);
user.Volatile.Returns(userVolatile);
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
user.BattleData.Returns((IPokemonBattleData?)null);
move.User.Returns(user);
move.MoveChoice.Returns(Substitute.For<IMoveChoice>());
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<ChargeFlyEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: the attack executes on the second turn, ending the semi-invulnerable state. Once the
/// attack executes, the <see cref="ChargeFlyEffect"/> is removed from the user.
/// </summary>
[Test]
public async Task OnBeforeMove_ChargeCompleted_RemovesChargeFlyEffect()
{
// Arrange
var (script, move, user, userVolatile, _, _) = CreateTestSetup();
userVolatile.Add(new ChargeFlyEffect(user));
// Act
script.OnBeforeMove(move);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<ChargeFlyEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: while flying up high the user is "semi-invulnerable". The <see cref="ChargeFlyEffect"/>
/// blocks incoming hits from moves that cannot hit flying Pokémon.
/// </summary>
[Test]
public async Task BlockIncomingHit_MoveCannotHitFlying_BlocksHit()
{
// Arrange
var (script, move, user, userVolatile, _, _) = CreateTestSetup();
var prevent = false;
script.PreventMove(move, ref prevent);
await Assert.That(userVolatile.TryGet<ChargeFlyEffect>(out var effect)).IsTrue();
var incomingMove = Substitute.For<IExecutingMove>();
var incomingMoveData = Substitute.For<IMoveData>();
incomingMoveData.HasFlag(new StringKey("hit_flying")).Returns(false);
incomingMove.UseMove.Returns(incomingMoveData);
// Act
var block = false;
effect!.BlockIncomingHit(incomingMove, user, 0, ref block);
// Assert
await Assert.That(block).IsTrue();
}
/// <summary>
/// Bulbapedia: the user "can now be hit by Gust, Thunder, Twister, and Whirlwind during the
/// semi-invulnerable turn of Fly" (and from Gen V onwards also "Hurricane, Smack Down, and Thousand
/// Arrows"). Moves flagged as able to hit flying Pokémon are not blocked.
/// </summary>
[Test]
public async Task BlockIncomingHit_MoveCanHitFlying_DoesNotBlockHit()
{
// Arrange
var (script, move, user, userVolatile, _, _) = CreateTestSetup();
var prevent = false;
script.PreventMove(move, ref prevent);
await Assert.That(userVolatile.TryGet<ChargeFlyEffect>(out var effect)).IsTrue();
var incomingMove = Substitute.For<IExecutingMove>();
var incomingMoveData = Substitute.For<IMoveData>();
incomingMoveData.HasFlag(new StringKey("hit_flying")).Returns(true);
incomingMove.UseMove.Returns(incomingMoveData);
// Act
var block = false;
effect!.BlockIncomingHit(incomingMove, user, 0, ref block);
// Assert
await Assert.That(block).IsFalse();
}
/// <summary>
/// Bulbapedia: the user "will receive double damage from Gust and Twister" while in the
/// semi-invulnerable turn of Fly. A move flagged as effective against flying Pokémon deals doubled
/// damage to the flying user.
/// </summary>
[Test]
public async Task ChangeIncomingMoveDamage_GustLikeMove_DamageIsDoubled()
{
// Arrange
var (script, move, user, userVolatile, _, _) = CreateTestSetup();
var prevent = false;
script.PreventMove(move, ref prevent);
await Assert.That(userVolatile.TryGet<ChargeFlyEffect>(out var effect)).IsTrue();
var incomingMove = Substitute.For<IExecutingMove>();
var incomingMoveData = Substitute.For<IMoveData>();
incomingMoveData.HasFlag(new StringKey("hit_flying")).Returns(true);
incomingMoveData.HasFlag(new StringKey("effective_against_fly")).Returns(true);
incomingMove.UseMove.Returns(incomingMoveData);
uint damage = 100;
// Act
effect!.ChangeIncomingMoveDamage(incomingMove, user, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(200u);
}
/// <summary>
/// Bulbapedia: only Gust and Twister deal "double damage" to the flying user; other moves that can hit
/// during the semi-invulnerable turn (such as Thunder, Hurricane, and Smack Down) deal regular damage.
/// </summary>
[Test]
public async Task ChangeIncomingMoveDamage_ThunderLikeMove_DamageIsUnchanged()
{
// Arrange
var (script, move, user, userVolatile, _, _) = CreateTestSetup();
var prevent = false;
script.PreventMove(move, ref prevent);
await Assert.That(userVolatile.TryGet<ChargeFlyEffect>(out var effect)).IsTrue();
var incomingMove = Substitute.For<IExecutingMove>();
var incomingMoveData = Substitute.For<IMoveData>();
incomingMoveData.HasFlag(new StringKey("hit_flying")).Returns(true);
incomingMoveData.HasFlag(new StringKey("effective_against_fly")).Returns(false);
incomingMove.UseMove.Returns(incomingMoveData);
uint damage = 100;
// Act
effect!.ChangeIncomingMoveDamage(incomingMove, user, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(100u);
}
/// <summary>
/// Bulbapedia: "If the user of Fly is hit by Smack Down or Thousand Arrows during its
/// semi-invulnerable turn, it will be knocked down and Fly's execution will be cancelled" — more
/// generally, when the user's executed choice was not the Fly charge (the move was disrupted or
/// replaced), the semi-invulnerable state is removed.
/// </summary>
[Test]
public async Task OnAfterMoveChoice_ChoiceIsNotFlyCharge_RemovesChargeFlyEffect()
{
// Arrange
var user = Substitute.For<IPokemon>();
var userVolatile = new ScriptSet(user);
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var effect = new ChargeFlyEffect(user);
userVolatile.Add(effect);
var choice = Substitute.For<IMoveChoice>();
choice.AdditionalData.Returns((Dictionary<StringKey, object?>?)null);
// Act
effect.OnAfterMoveChoice(choice);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<ChargeFlyEffect>())).IsFalse();
}
/// <summary>
/// The charging turn itself carries the "fly_charge" marker; the <see cref="ChargeFlyEffect"/> must
/// survive that turn so the user stays airborne until the attack turn.
/// </summary>
[Test]
public async Task OnAfterMoveChoice_ChoiceIsFlyCharge_KeepsChargeFlyEffect()
{
// Arrange
var user = Substitute.For<IPokemon>();
var userVolatile = new ScriptSet(user);
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var effect = new ChargeFlyEffect(user);
userVolatile.Add(effect);
var choice = Substitute.For<IMoveChoice>();
choice.AdditionalData.Returns(new Dictionary<StringKey, object?> { { "fly_charge", true } });
// Act
effect.OnAfterMoveChoice(choice);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<ChargeFlyEffect>())).IsTrue();
}
}

View File

@@ -0,0 +1,96 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Libraries;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FlyingPress"/> script, which implements Flying Press.
/// Behavior is verified against the Bulbapedia page for Flying Press (Generation VII).
/// </summary>
public class FlyingPressTests
{
/// <summary>
/// Creates a fully mocked test setup for Flying Press tests, with a type library containing both the
/// Fighting and Flying types.
/// </summary>
private static (FlyingPress flyingPress, IExecutingMove move, IPokemon target, TypeIdentifier fighting,
TypeIdentifier flying) CreateTestSetup()
{
var flyingPress = new FlyingPress();
var typeLibrary = new TypeLibrary();
var fighting = typeLibrary.RegisterType("fighting");
var flying = typeLibrary.RegisterType("flying");
var move = Substitute.For<IExecutingMove>();
move.User.Library.StaticLibrary.Types.Returns(typeLibrary);
var target = Substitute.For<IPokemon>();
return (flyingPress, move, target, fighting, flying);
}
/// <summary>
/// Bulbapedia: "Despite being a Fighting move, the damage dealt is actually a combination of Fighting and
/// Flying types, and thus its effectiveness against a given Pokémon differs from other Fighting-type moves."
/// The script adds the Flying type to the list of types used for the move's effectiveness calculation.
/// </summary>
[Test]
public async Task ChangeTypesForMove_FlyingTypeAvailable_AddsFlyingTypeToMoveTypes()
{
// Arrange
var (flyingPress, move, target, fighting, flying) = CreateTestSetup();
IList<TypeIdentifier> types = new List<TypeIdentifier> { fighting };
// Act
flyingPress.ChangeTypesForMove(move, target, 0, types);
// Assert
await Assert.That(types.Contains(flying)).IsTrue();
}
/// <summary>
/// Bulbapedia: "However, for all other purposes, it is a Fighting-type move: only Fighting-type Pokémon can
/// receive the same-type attack bonus on Flying Press".
/// The Flying type is added on top of the move's own Fighting type; the original type is not replaced.
/// </summary>
[Test]
public async Task ChangeTypesForMove_FlyingTypeAvailable_KeepsOriginalFightingType()
{
// Arrange
var (flyingPress, move, target, fighting, _) = CreateTestSetup();
IList<TypeIdentifier> types = new List<TypeIdentifier> { fighting };
// Act
flyingPress.ChangeTypesForMove(move, target, 0, types);
// Assert - the Fighting type is still the first type, and exactly one type was added
await Assert.That(types[0]).IsEqualTo(fighting);
await Assert.That(types.Count).IsEqualTo(2);
}
/// <summary>
/// Bulbapedia: "the damage dealt is actually a combination of Fighting and Flying types".
/// Technical test: if the type library does not know a "flying" type, the script leaves the move's types
/// unchanged instead of adding a default type identifier.
/// </summary>
[Test]
public async Task ChangeTypesForMove_FlyingTypeMissingFromLibrary_TypesUnchanged()
{
// Arrange
var flyingPress = new FlyingPress();
var typeLibrary = new TypeLibrary();
var fighting = typeLibrary.RegisterType("fighting");
var move = Substitute.For<IExecutingMove>();
move.User.Library.StaticLibrary.Types.Returns(typeLibrary);
var target = Substitute.For<IPokemon>();
IList<TypeIdentifier> types = new List<TypeIdentifier> { fighting };
// Act
flyingPress.ChangeTypesForMove(move, target, 0, types);
// Assert
await Assert.That(types.Count).IsEqualTo(1);
await Assert.That(types[0]).IsEqualTo(fighting);
}
}

View File

@@ -0,0 +1,125 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FocusEnergy"/> script, which implements Focus Energy.
/// Behavior is verified against the Bulbapedia page for Focus Energy (Generation VII behavior: the
/// Generation II base effect with the Generation III change applied).
/// The lasting part of the effect (the raised critical hit ratio) is implemented by the
/// <see cref="FocusEnergyEffect"/> volatile script, which the move script is responsible for attaching
/// to the user.
/// </summary>
public class FocusEnergyTests
{
/// <summary>
/// Creates a fully mocked test setup for Focus Energy tests.
/// </summary>
private static (FocusEnergy focusEnergy, IExecutingMove move, IPokemon target, IScriptSet volatileSet, IHitData
hitData) CreateTestSetup()
{
var focusEnergy = new FocusEnergy();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
var volatileSet = Substitute.For<IScriptSet>();
target.Volatile.Returns(volatileSet);
return (focusEnergy, move, target, volatileSet, hitData);
}
/// <summary>
/// Helper to extract the script passed to the volatile set's Add call.
/// </summary>
private static Script? GetAddedScript(IScriptSet volatileSet)
{
var call = volatileSet.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Add");
return call?.GetArguments()[0] as Script;
}
/// <summary>
/// Bulbapedia (Generation II): "Focus Energy now permanently increases the user's critical hit ratio."
/// The lasting effect is implemented by attaching a <see cref="FocusEnergyEffect"/> volatile script to the
/// target of the move (Focus Energy is self-targeted, so the target is the user).
/// </summary>
[Test]
public async Task OnSecondaryEffect_Called_AddsFocusEnergyEffectToTargetVolatile()
{
// Arrange
var (focusEnergy, move, target, volatileSet, _) = CreateTestSetup();
// Act
focusEnergy.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetAddedScript(volatileSet) is FocusEnergyEffect).IsTrue();
}
/// <summary>
/// Bulbapedia (Generations III to V, unchanged through Generation VII): "Focus Energy now increases the
/// user's critical hit ratio by two stages instead of one."
/// The volatile effect applied by <see cref="FocusEnergy"/> must therefore raise the critical hit stage
/// by two when the affected Pokémon attacks.
/// </summary>
[Test, Arguments((byte)0, (byte)2), Arguments((byte)1, (byte)3), Arguments((byte)2, (byte)4)]
public async Task OnSecondaryEffect_AppliedEffect_RaisesCriticalHitStageByTwoStages(byte initialStage,
byte expectedStage)
{
// Arrange
var (focusEnergy, move, target, volatileSet, _) = CreateTestSetup();
focusEnergy.OnSecondaryEffect(move, target, 0);
var addedScript = GetAddedScript(volatileSet);
await Assert.That(addedScript is IScriptChangeCriticalStage).IsTrue();
// Act - run the applied effect's critical stage hook, as would happen when the user attacks
var stage = initialStage;
((IScriptChangeCriticalStage)addedScript!).ChangeCriticalStage(move, target, 0, ref stage);
// Assert
await Assert.That(stage).IsEqualTo(expectedStage);
}
/// <summary>
/// Bulbapedia: "The effect of Focus Energy cannot stack, and it will fail if the user is already under its
/// effect." (Stated for Generation I and never reverted by a later generational change, so still the
/// behavior in Generation VII.)
/// When the user already has the <see cref="FocusEnergyEffect"/> volatile script, the hit must fail.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserAlreadyUnderEffect_HitFails()
{
// Arrange
var (focusEnergy, move, target, volatileSet, hitData) = CreateTestSetup();
volatileSet.Contains<FocusEnergyEffect>().Returns(true);
// Act
focusEnergy.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue();
}
/// <summary>
/// Bulbapedia: "The effect of Focus Energy cannot stack". A second use on a user that is already under the
/// effect must not attach a second <see cref="FocusEnergyEffect"/> volatile script, which would otherwise
/// stack another two critical hit stages on top of the existing ones.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserAlreadyUnderEffect_DoesNotAddEffectAgain()
{
// Arrange
var (focusEnergy, move, target, volatileSet, _) = CreateTestSetup();
volatileSet.Contains<FocusEnergyEffect>().Returns(true);
// Act
focusEnergy.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetAddedScript(volatileSet)).IsNull();
}
}

View File

@@ -0,0 +1,229 @@
using PkmnLib.Dynamic.Events;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FocusPunch"/> script, which implements Focus Punch.
/// Behavior is verified against the Bulbapedia page for Focus Punch (Generation VII).
/// </summary>
public class FocusPunchTests
{
/// <summary>
/// Creates a fully mocked test setup for the turn start (charging) phase of Focus Punch.
/// </summary>
private static (FocusPunch focusPunch, ITurnChoice choice, IPokemon user, IScriptSet volatileSet, EventHook
eventHook) CreateChargeSetup(bool hasBattleData = true)
{
var focusPunch = new FocusPunch();
var user = Substitute.For<IPokemon>();
var volatileSet = Substitute.For<IScriptSet>();
user.Volatile.Returns(volatileSet);
var eventHook = new EventHook();
if (hasBattleData)
{
var battle = Substitute.For<IBattle>();
battle.EventHook.Returns(eventHook);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
user.BattleData.Returns(battleData);
}
else
{
user.BattleData.Returns((IPokemonBattleData?)null);
}
var choice = Substitute.For<ITurnChoice>();
choice.User.Returns(user);
return (focusPunch, choice, user, volatileSet, eventHook);
}
/// <summary>
/// Creates a fully mocked test setup for the execution (PreventMove) phase of Focus Punch.
/// </summary>
private static (FocusPunch focusPunch, IExecutingMove move, IScriptSet volatileSet) CreateExecutionSetup()
{
var focusPunch = new FocusPunch();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
var volatileSet = Substitute.For<IScriptSet>();
user.Volatile.Returns(volatileSet);
move.User.Returns(user);
return (focusPunch, move, volatileSet);
}
/// <summary>
/// Creates a <see cref="FocusPunchEffect"/> that has registered an incoming hit, as happens when the user
/// is struck by a damaging move while focusing.
/// </summary>
private static FocusPunchEffect CreateHitEffect()
{
var effect = new FocusPunchEffect();
var hitReceiver = Substitute.For<IPokemon>();
hitReceiver.BattleData.Returns((IPokemonBattleData?)null);
effect.OnIncomingHit(Substitute.For<IExecutingMove>(), hitReceiver, 0);
return effect;
}
/// <summary>
/// Bulbapedia: "The user of Focus Punch will start focusing at the beginning of the turn the move is used".
/// At turn start the script attaches the <see cref="FocusPunchEffect"/> volatile to the user, which tracks
/// whether the user is hit during the turn.
/// </summary>
[Test]
public async Task OnBeforeTurnStart_Called_AddsFocusPunchEffectToUserVolatile()
{
// Arrange
var (focusPunch, choice, _, volatileSet, _) = CreateChargeSetup();
// Act
focusPunch.OnBeforeTurnStart(choice);
// Assert
var addCall = volatileSet.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Add");
await Assert.That(addCall).IsNotNull();
await Assert.That(addCall!.GetArguments()[0] is FocusPunchEffect).IsTrue();
}
/// <summary>
/// Bulbapedia: "Its focusing message is displayed before any other moves."
/// The script fires a <see cref="DialogEvent"/> with the "focus_punch_charge" message at turn start,
/// with the charging Pokémon as parameter.
/// </summary>
[Test]
public async Task OnBeforeTurnStart_Called_FiresFocusPunchChargeDialogEvent()
{
// Arrange
var (focusPunch, choice, user, _, eventHook) = CreateChargeSetup();
DialogEvent? capturedEvent = null;
eventHook.Handler += (sender, args) =>
{
if (args is DialogEvent dialogEvent)
capturedEvent = dialogEvent;
};
// Act
focusPunch.OnBeforeTurnStart(choice);
// Assert
await Assert.That(capturedEvent).IsNotNull();
await Assert.That(capturedEvent!.Message).IsEqualTo("focus_punch_charge");
await Assert.That(capturedEvent.Parameters).IsNotNull();
await Assert.That(capturedEvent.Parameters!.ContainsKey("pokemon")).IsTrue();
await Assert.That(ReferenceEquals(capturedEvent.Parameters["pokemon"], user)).IsTrue();
}
/// <summary>
/// Bulbapedia: "The user of Focus Punch will start focusing at the beginning of the turn the move is used".
/// Technical test: if the user has no <see cref="IPokemon.BattleData"/>, no dialog can be shown, but the
/// charging effect is still applied without throwing.
/// </summary>
[Test]
public async Task OnBeforeTurnStart_UserWithoutBattleData_StillAddsFocusPunchEffect()
{
// Arrange
var (focusPunch, choice, _, volatileSet, _) = CreateChargeSetup(false);
// Act
focusPunch.OnBeforeTurnStart(choice);
// Assert
var addCall = volatileSet.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Add");
await Assert.That(addCall).IsNotNull();
await Assert.That(addCall!.GetArguments()[0] is FocusPunchEffect).IsTrue();
}
/// <summary>
/// Bulbapedia: "then execute the move Focus Punch at a decreased priority, unless it was hit by another
/// Pokémon's damaging move before executing Focus Punch".
/// If the user was not hit while focusing, the move is not prevented.
/// </summary>
[Test]
public async Task PreventMove_UserNotHitThisTurn_MoveIsNotPrevented()
{
// Arrange
var (focusPunch, move, volatileSet) = CreateExecutionSetup();
volatileSet.Get<FocusPunchEffect>().Returns(new FocusPunchEffect());
var prevent = false;
// Act
focusPunch.PreventMove(move, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia: "The move fails if the user has already taken damage from a move in the same turn."
/// Generation V onwards: "PP is no longer consumed if the user loses its focus and fails to execute the
/// move." — the script uses the PreventMove hook, which does not consume PP.
/// </summary>
[Test]
public async Task PreventMove_UserWasHitThisTurn_MoveIsPrevented()
{
// Arrange
var (focusPunch, move, volatileSet) = CreateExecutionSetup();
// Create the effect before configuring the substitute: CreateHitEffect interacts with other substitutes,
// which would make NSubstitute configure the wrong call if evaluated inside Returns().
var hitEffect = CreateHitEffect();
volatileSet.Get<FocusPunchEffect>().Returns(hitEffect);
var prevent = false;
// Act
focusPunch.PreventMove(move, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "The user of Focus Punch will start focusing at the beginning of the turn the move is used".
/// Technical test: if the user never started focusing (no <see cref="FocusPunchEffect"/> volatile is
/// present), the move cannot execute.
/// </summary>
[Test]
public async Task PreventMove_NoFocusPunchEffectPresent_MoveIsPrevented()
{
// Arrange
var (focusPunch, move, volatileSet) = CreateExecutionSetup();
volatileSet.Get<FocusPunchEffect>().Returns((FocusPunchEffect?)null);
var prevent = false;
// Act
focusPunch.PreventMove(move, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia (Generation V onwards): "One-hit knockout moves do not cause Focus Punch to lose focus."
/// A one-hit knockout move can land on a focusing Pokémon that survives it (e.g. through Sturdy or a Focus
/// Sash), in which case the engine invokes <see cref="FocusPunchEffect"/>'s incoming-hit hook; the effect
/// must then recognize the move (Gen7 data marks these with the "one_hit_ko" effect, here real Fissure
/// data) and keep its focus.
/// </summary>
[Test]
public async Task OnIncomingHit_HitByOneHitKnockoutMove_FocusIsNotBroken()
{
// Arrange
var library = LibraryHelpers.LoadLibrary();
await Assert.That(library.StaticLibrary.Moves.TryGet("fissure", out var fissure)).IsTrue();
var effect = new FocusPunchEffect();
var move = Substitute.For<IExecutingMove>();
move.UseMove.Returns(fissure!);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns((IPokemonBattleData?)null);
// Act
effect.OnIncomingHit(move, target, 0);
// Assert
await Assert.That(effect.WasHit).IsFalse();
}
}

View File

@@ -0,0 +1,421 @@
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>();
}
}

View File

@@ -0,0 +1,221 @@
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static;
using PkmnLib.Static.Libraries;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Foresight"/> move script.
/// Behavior is verified against the Bulbapedia page for Foresight (Generation VII behavior: the
/// Generation II base effect with the Generation III, IV and V-to-VII changes applied).
/// The lasting part of the effect is implemented by the <see cref="ForesightEffect"/> volatile script,
/// which the move script is responsible for attaching to the target.
/// </summary>
public class ForesightTests
{
/// <summary>
/// Creates a fully mocked test setup for Foresight tests. The target is in battle, with a mocked
/// battle library chain and the given evasion stat stage.
/// </summary>
private static (Foresight script, IExecutingMove move, IPokemon target, IScriptSet volatileSet, IHitData hitData)
CreateTestSetup(sbyte evasionStage = 0)
{
var script = new Foresight();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
var typeLibrary = Substitute.For<IReadOnlyTypeLibrary>();
var staticLibrary = Substitute.For<IStaticLibrary>();
staticLibrary.Types.Returns(typeLibrary);
var library = Substitute.For<IDynamicLibrary>();
library.StaticLibrary.Returns(staticLibrary);
var battle = Substitute.For<IBattle>();
battle.Library.Returns(library);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
target.BattleData.Returns(battleData);
var volatileSet = Substitute.For<IScriptSet>();
target.Volatile.Returns(volatileSet);
target.StatBoost.Returns(new StatBoostStatisticSet
{
Evasion = evasionStage,
});
return (script, move, target, volatileSet, hitData);
}
/// <summary>
/// Helper to extract all stat boost changes requested on the target through
/// <see cref="IPokemon.ChangeStatBoost"/>.
/// </summary>
private static IReadOnlyList<(Statistic stat, sbyte amount)> GetStatBoostChanges(IPokemon target) =>
target.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost")
.Select(c => ((Statistic)c.GetArguments()[0]!, (sbyte)c.GetArguments()[1]!)).ToList();
/// <summary>
/// Bulbapedia: "Foresight also removes the Ghost type's immunity to Fighting and Normal moves for any
/// moves used against the target. This effect remains until the target switches out".
/// The lasting effect is implemented by attaching a <see cref="ForesightEffect"/> volatile script
/// to the target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetInBattle_AddsForesightEffectToTargetVolatile()
{
// Arrange
var (script, move, target, volatileSet, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var addCall = volatileSet.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Add");
await Assert.That(addCall).IsNotNull();
await Assert.That(addCall!.GetArguments()[0] is ForesightEffect).IsTrue();
}
/// <summary>
/// Bulbapedia (Generation III): "Foresight now causes all accuracy checks against the target to ignore
/// any changes to the target's evasion stat stages."
/// The engine models this by resetting a raised evasion stat stage back to zero (and preventing further
/// changes through <see cref="ForesightEffect"/>).
/// </summary>
[Test, Arguments((sbyte)1, (sbyte)-1), Arguments((sbyte)3, (sbyte)-3), Arguments((sbyte)6, (sbyte)-6)]
public async Task OnSecondaryEffect_PositiveEvasionStage_EvasionStageResetToZero(sbyte evasionStage,
sbyte expectedChange)
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup(evasionStage);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var changes = GetStatBoostChanges(target);
await Assert.That(changes.Count).IsEqualTo(1);
await Assert.That(changes[0].stat).IsEqualTo(Statistic.Evasion);
await Assert.That(changes[0].amount).IsEqualTo(expectedChange);
}
/// <summary>
/// Bulbapedia (Generation IV): "Foresight now only causes accuracy checks against the target to ignore
/// changes to its evasion stat stages if its evasion stat stage is greater than 0."
/// A lowered evasion stat stage therefore remains in effect: Foresight must not raise it back to zero.
/// </summary>
[Test, Arguments((sbyte)-1), Arguments((sbyte)-6)]
public async Task OnSecondaryEffect_NegativeEvasionStage_EvasionNotRaised(sbyte evasionStage)
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup(evasionStage);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - a negative evasion stage may not be raised back towards zero
var changes = GetStatBoostChanges(target);
await Assert.That(changes.Any(c => c.stat == Statistic.Evasion && c.amount > 0)).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation III): "Foresight now causes all accuracy checks against the target to ignore
/// any changes to the target's evasion stat stages."
/// With an unchanged (zero) evasion stat stage there is nothing to reset, so no effective stat change
/// may be requested.
/// </summary>
[Test]
public async Task OnSecondaryEffect_ZeroEvasionStage_NoEffectiveEvasionChange()
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var changes = GetStatBoostChanges(target);
await Assert.That(changes.Any(c => c.amount != 0)).IsFalse();
}
/// <summary>
/// If the target has no <see cref="IPokemon.BattleData"/>, the script cannot resolve the type library,
/// so nothing may happen: no volatile script is added and no stat stage is changed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NullBattleData_NoEffect()
{
// Arrange
var (script, move, target, volatileSet, _) = CreateTestSetup(2);
target.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Add")).IsFalse();
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
}
/// <summary>
/// Bulbapedia (Generations V to VII): "Foresight will once again fail if used against a Pokémon already
/// under its effect."
/// When the target already has the <see cref="ForesightEffect"/> volatile script, the hit must fail.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetAlreadyUnderEffect_HitFails()
{
// Arrange
var (script, move, target, volatileSet, hitData) = CreateTestSetup();
volatileSet.Contains<ForesightEffect>().Returns(true);
volatileSet.Contains(Arg.Any<StringKey>()).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue();
}
/// <summary>
/// Bulbapedia (Generation III): "Foresight now causes all accuracy checks against the target to ignore
/// any changes to the target's evasion stat stages."
/// For the evasion reset to actually take effect in a real battle, it must not be blocked by the move's
/// own <see cref="ForesightEffect"/>: that script's <c>PreventStatBoostChange</c> hook prevents all
/// evasion changes once attached. The reset must therefore either happen before the volatile script is
/// added to the target, or be requested with <c>force: true</c>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_PositiveEvasionStage_ResetNotBlockedByOwnEffect()
{
// Arrange
var (script, move, target, volatileSet, _) = CreateTestSetup(2);
var callOrder = new List<string>();
volatileSet.Add(null!).ReturnsForAnyArgs(ci =>
{
callOrder.Add("Add");
return (ScriptContainer?)null;
});
target.ChangeStatBoost(default, default, default, default).ReturnsForAnyArgs(ci =>
{
callOrder.Add(ci.ArgAt<bool>(3) ? "ChangeStatBoost:forced" : "ChangeStatBoost");
return true;
});
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - the evasion reset must be able to take effect: either it happens before the
// ForesightEffect volatile (which prevents evasion changes) is attached, or it is forced.
var boostIndex = callOrder.FindIndex(c => c.StartsWith("ChangeStatBoost"));
var addIndex = callOrder.IndexOf("Add");
await Assert.That(boostIndex).IsNotEqualTo(-1);
await Assert.That(addIndex).IsNotEqualTo(-1);
await Assert.That(callOrder.Contains("ChangeStatBoost:forced") || boostIndex < addIndex).IsTrue();
}
}

View File

@@ -0,0 +1,226 @@
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Libraries;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="ForestsCurse"/> move script.
/// Behavior is verified against the Bulbapedia page for Forest's Curse (Generation VII behavior).
/// </summary>
public class ForestsCurseTests
{
private static readonly TypeIdentifier GrassType = new(5, "grass");
private static readonly TypeIdentifier NormalType = new(1, "normal");
private static readonly TypeIdentifier GhostType = new(8, "ghost");
/// <summary>
/// Creates a fully mocked test setup for Forest's Curse tests. The target is in battle, with a mocked
/// battle library chain that can resolve the Grass type identifier.
/// </summary>
private static (ForestsCurse script, IExecutingMove move, IPokemon target, IHitData hitData) CreateTestSetup(
bool grassTypeInLibrary = true, TypeIdentifier[]? targetTypes = null)
{
var script = new ForestsCurse();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
var typeLibrary = Substitute.For<IReadOnlyTypeLibrary>();
if (grassTypeInLibrary)
{
typeLibrary.TryGetTypeIdentifier(new StringKey("grass"), out Arg.Any<TypeIdentifier>()).Returns(x =>
{
x[1] = GrassType;
return true;
});
}
var staticLibrary = Substitute.For<IStaticLibrary>();
staticLibrary.Types.Returns(typeLibrary);
var library = Substitute.For<IDynamicLibrary>();
library.StaticLibrary.Returns(staticLibrary);
var battle = Substitute.For<IBattle>();
battle.Library.Returns(library);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
target.BattleData.Returns(battleData);
target.Types.Returns(targetTypes ?? [NormalType]);
return (script, move, target, hitData);
}
/// <summary>
/// Helper to extract the type passed to the target's <see cref="IPokemon.AddType"/> call.
/// </summary>
private static TypeIdentifier? GetAddedType(IPokemon target)
{
var call = target.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "AddType");
return call != null ? (TypeIdentifier)call.GetArguments()[0]! : null;
}
/// <summary>
/// Bulbapedia: "Forest's Curse adds the Grass type to the target, in addition to the Pokémon's
/// original type(s)."
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetInBattle_AddsGrassTypeToTarget()
{
// Arrange
var (script, move, target, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var addedType = GetAddedType(target);
await Assert.That(addedType).IsNotNull();
await Assert.That(addedType!.Value).IsEqualTo(GrassType);
}
/// <summary>
/// Bulbapedia: the Grass type is added "in addition to the Pokémon's original type(s)".
/// The target's original types may not be replaced, so <see cref="IPokemon.SetTypes"/> must not be used.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetInBattle_OriginalTypesNotReplaced()
{
// Arrange
var (script, move, target, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetTypes")).IsFalse();
}
/// <summary>
/// Bulbapedia: Forest's Curse fails if the target is already Grass-type ("The move fails against
/// Grass-type targets"), so the hit must be marked as failed and no type may be added.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetAlreadyGrassType_HitFails()
{
// Arrange
var (script, move, target, hitData) = CreateTestSetup(targetTypes: [GrassType]);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue();
}
/// <summary>
/// If the target has no <see cref="IPokemon.BattleData"/>, the script cannot resolve the type library,
/// so no type may be added.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NullBattleData_DoesNotAddType()
{
// Arrange
var (script, move, target, _) = CreateTestSetup();
target.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "AddType")).IsFalse();
}
/// <summary>
/// Technical test: when the type library cannot resolve the Grass type identifier, the script must
/// bail out without adding a type.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GrassTypeMissingFromLibrary_DoesNotAddType()
{
// Arrange
var (script, move, target, _) = CreateTestSetup(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "AddType")).IsFalse();
}
/// <summary>
/// Bulbapedia: "If the target already has an additional type added to it by Trick-or-Treat, that type is
/// replaced with the Grass type."
/// A target whose Ghost type was added by <see cref="TrickOrTreat"/> must end up with the Grass type and
/// without the Ghost type, while keeping its original type. The mocked target uses a real
/// <see cref="ScriptSet"/> for its volatile scripts, so the <c>HasHadTypeAddedEffect</c> marker set by
/// Trick-or-Treat is visible to Forest's Curse, and reacts to <see cref="IPokemon.AddType"/> and
/// <see cref="IPokemon.RemoveType"/> with a backing type list, mirroring the real Pokemon implementation,
/// so the resulting type list is observable.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHasTrickOrTreatGhostType_GhostReplacedByGrass()
{
// Arrange
var forestsCurse = new ForestsCurse();
var trickOrTreat = new TrickOrTreat();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var typeLibrary = Substitute.For<IReadOnlyTypeLibrary>();
typeLibrary.TryGetTypeIdentifier(new StringKey("grass"), out Arg.Any<TypeIdentifier>()).Returns(x =>
{
x[1] = GrassType;
return true;
});
typeLibrary.TryGetTypeIdentifier(new StringKey("ghost"), out Arg.Any<TypeIdentifier>()).Returns(x =>
{
x[1] = GhostType;
return true;
});
var staticLibrary = Substitute.For<IStaticLibrary>();
staticLibrary.Types.Returns(typeLibrary);
var library = Substitute.For<IDynamicLibrary>();
library.StaticLibrary.Returns(staticLibrary);
var battle = Substitute.For<IBattle>();
battle.Library.Returns(library);
move.Battle.Returns(battle);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
target.BattleData.Returns(battleData);
// Real script set so the HasHadTypeAddedEffect marker added by Trick-or-Treat is visible
// to Forest's Curse.
target.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
IScriptSet targetVolatile = new ScriptSet(target);
target.Volatile.Returns(targetVolatile);
// Backing type list that reacts to AddType/RemoveType like the real Pokemon implementation.
var types = new List<TypeIdentifier> { NormalType };
target.Types.Returns(_ => types);
target.AddType(Arg.Any<TypeIdentifier>()).Returns(ci =>
{
var type = ci.Arg<TypeIdentifier>();
if (types.Contains(type))
return false;
types.Add(type);
return true;
});
target.RemoveType(Arg.Any<TypeIdentifier>()).Returns(ci => types.Remove(ci.Arg<TypeIdentifier>()));
// Trick-or-Treat first adds the Ghost type to the target.
trickOrTreat.OnSecondaryEffect(move, target, 0);
// Act
forestsCurse.OnSecondaryEffect(move, target, 0);
// Assert - the Ghost type added by Trick-or-Treat is replaced by Grass; the original type remains.
await Assert.That(types.Contains(GrassType)).IsTrue();
await Assert.That(types.Contains(GhostType)).IsFalse();
await Assert.That(types.Contains(NormalType)).IsTrue();
}
}

View File

@@ -0,0 +1,94 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FoulPlay"/> move script.
/// Behavior is verified against the Bulbapedia page for Foul Play (Generation VII behavior).
/// </summary>
public class FoulPlayTests
{
/// <summary>
/// Creates a fully mocked test setup for Foul Play tests, with a target whose (boosted) offensive
/// stats are set to the given values.
/// </summary>
private static (FoulPlay script, IExecutingMove move, IPokemon target) CreateTestSetup(MoveCategory category,
uint targetAttack, uint targetSpecialAttack)
{
var script = new FoulPlay();
var move = Substitute.For<IExecutingMove>();
var useMove = Substitute.For<IMoveData>();
useMove.Category.Returns(category);
move.UseMove.Returns(useMove);
var target = Substitute.For<IPokemon>();
target.BoostedStats.Returns(new StatisticSet<uint>(100, targetAttack, 80, targetSpecialAttack, 80, 80));
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
return (script, move, target);
}
/// <summary>
/// Bulbapedia: "Foul Play inflicts damage. It uses the target's Attack stat to calculate damage instead
/// of the user's."
/// The offensive stat value (initially the user's Attack) must be replaced by the target's Attack.
/// </summary>
[Test, Arguments(50u), Arguments(150u), Arguments(400u)]
public async Task ChangeOffensiveStatValue_PhysicalMove_UsesTargetsAttackInsteadOfUsers(uint targetAttack)
{
// Arrange
var (script, move, target) = CreateTestSetup(MoveCategory.Physical, targetAttack, 80);
uint value = 100; // The user's own Attack stat, which must be discarded.
// Act
script.ChangeOffensiveStatValue(move, target, 0, 200, new StatisticSet<uint>(), Statistic.Attack, ref value);
// Assert
await Assert.That(value).IsEqualTo(targetAttack);
}
/// <summary>
/// Bulbapedia: "The target's Attack stat stage-modifiers are applied (rather than the user's)".
/// The stat used must be the target's boosted Attack (<see cref="IPokemon.BoostedStats"/>, which includes
/// stat stages), not its flat Attack (<see cref="IPokemon.FlatStats"/>).
/// </summary>
[Test]
public async Task ChangeOffensiveStatValue_PhysicalMove_TargetStatStagesApplied()
{
// Arrange - the target's flat Attack is 100, but its stat stages boost it to 400.
var (script, move, target) = CreateTestSetup(MoveCategory.Physical, 400, 80);
target.FlatStats.Returns(new StatisticSet<uint>(100, 100, 80, 80, 80, 80));
uint value = 100;
// Act
script.ChangeOffensiveStatValue(move, target, 0, 200, new StatisticSet<uint>(), Statistic.Attack, ref value);
// Assert
await Assert.That(value).IsEqualTo(400u);
}
/// <summary>
/// Technical test: the script generically supports special moves by using the target's Special Attack
/// instead. (Foul Play itself is a physical move; Bulbapedia: "It uses the target's Attack stat to
/// calculate damage instead of the user's.")
/// </summary>
[Test]
public async Task ChangeOffensiveStatValue_SpecialMove_UsesTargetsSpecialAttack()
{
// Arrange
var (script, move, target) = CreateTestSetup(MoveCategory.Special, 150, 240);
uint value = 100;
// Act
script.ChangeOffensiveStatValue(move, target, 0, 200, new StatisticSet<uint>(), Statistic.SpecialAttack,
ref value);
// Assert
await Assert.That(value).IsEqualTo(240u);
}
}

View File

@@ -0,0 +1,234 @@
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FreezeDry"/> move script.
/// Behavior is verified against the Bulbapedia page for Freeze-Dry (Generation VII behavior).
/// The effectiveness tests use the real Gen7 type chart, as the script's core behavior is rewriting
/// type effectiveness against Water-type targets.
/// </summary>
public class FreezeDryTests
{
/// <summary>
/// Creates a test setup with the real Gen7 type library, and a target in battle with the given types.
/// </summary>
private static (FreezeDry script, IExecutingMove move, IPokemon target, IDynamicLibrary library)
CreateEffectivenessSetup(params string[] targetTypes)
{
var script = new FreezeDry();
var library = LibraryHelpers.LoadLibrary();
var battle = Substitute.For<IBattle>();
battle.Library.Returns(library);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
target.Types.Returns(targetTypes.Select(name => GetTypeId(library, name)).ToList());
var move = Substitute.For<IExecutingMove>();
var hitData = Substitute.For<IHitData>();
hitData.Type.Returns(GetTypeId(library, "ice"));
move.GetHitData(Arg.Any<IPokemon>(), Arg.Any<byte>()).Returns(hitData);
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
return (script, move, target, library);
}
/// <summary>
/// Creates a fully mocked test setup for the secondary (freeze) effect tests, with a battle random
/// that reports the given result for effect chance rolls.
/// </summary>
private static (FreezeDry script, IExecutingMove move, IPokemon target, IPokemon user, IBattleRandom random)
CreateSecondaryEffectSetup(bool effectChanceResult)
{
var script = new FreezeDry();
var random = Substitute.For<IBattleRandom>();
random.EffectChance(Arg.Any<float>(), Arg.Any<IExecutingMove>(), Arg.Any<IPokemon>(), Arg.Any<byte>())
.Returns(effectChanceResult);
var battle = Substitute.For<IBattle>();
battle.Random.Returns(random);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
return (script, move, target, user, random);
}
/// <summary>
/// Helper to resolve a type identifier from the loaded Gen7 library.
/// </summary>
private static TypeIdentifier GetTypeId(IDynamicLibrary library, string name)
{
library.StaticLibrary.Types.TryGetTypeIdentifier(name, out var type);
return type;
}
/// <summary>
/// Bulbapedia: "Freeze-Dry will always deal supereffective (x2) damage to the Water-type, even during
/// Inverse Battles or if its type is changed."
/// Against a pure Water-type target, the effectiveness is always rewritten to exactly 2, regardless of
/// the naturally calculated incoming effectiveness.
/// </summary>
[Test, Arguments(0.5f), Arguments(1f), Arguments(2f)]
public async Task ChangeEffectiveness_PureWaterTarget_EffectivenessAlwaysBecomesDouble(float initialEffectiveness)
{
// Arrange
var (script, move, target, _) = CreateEffectivenessSetup("water");
var effectiveness = initialEffectiveness;
// Act
script.ChangeEffectiveness(move, target, 0, ref effectiveness);
// Assert
await Assert.That(effectiveness).IsEqualTo(2f);
}
/// <summary>
/// Bulbapedia: "Freeze-Dry will always deal supereffective (x2) damage to the Water-type".
/// The x2 replaces only Ice's normal matchup against the Water type; the move's regular effectiveness
/// against the target's other type still applies. For example, against a Water/Flying Pokémon such as
/// Gyarados, Freeze-Dry is 4x effective (2x from Water, 2x from Ice against Flying).
/// </summary>
[Test, Arguments("flying", 4f), Arguments("grass", 4f), Arguments("ground", 4f), Arguments("ice", 1f),
Arguments("steel", 1f)]
public async Task ChangeEffectiveness_DualTypeWaterTarget_DoubleAgainstWaterTimesOtherTypeEffectiveness(
string otherType, float expected)
{
// Arrange
var (script, move, target, library) = CreateEffectivenessSetup("water", otherType);
// Start from the naturally calculated effectiveness of an Ice-type move against the target.
var effectiveness = library.StaticLibrary.Types.GetEffectiveness(GetTypeId(library, "ice"), target.Types);
// Act
script.ChangeEffectiveness(move, target, 0, ref effectiveness);
// Assert
await Assert.That(effectiveness).IsEqualTo(expected);
}
/// <summary>
/// Bulbapedia: the guaranteed super effectiveness applies "to the Water-type". Against a target without
/// the Water type, the effectiveness must be left untouched.
/// </summary>
[Test]
public async Task ChangeEffectiveness_NonWaterTarget_EffectivenessUnchanged()
{
// Arrange
var (script, move, target, _) = CreateEffectivenessSetup("grass");
var effectiveness = 2f;
// Act
script.ChangeEffectiveness(move, target, 0, ref effectiveness);
// Assert
await Assert.That(effectiveness).IsEqualTo(2f);
}
/// <summary>
/// If the target has no <see cref="IPokemon.BattleData"/>, the script cannot resolve the type library,
/// so the effectiveness must be left untouched.
/// </summary>
[Test]
public async Task ChangeEffectiveness_NullBattleData_EffectivenessUnchanged()
{
// Arrange
var (script, move, target, _) = CreateEffectivenessSetup("water");
target.BattleData.Returns((IPokemonBattleData?)null);
var effectiveness = 0.5f;
// Act
script.ChangeEffectiveness(move, target, 0, ref effectiveness);
// Assert
await Assert.That(effectiveness).IsEqualTo(0.5f);
}
/// <summary>
/// Bulbapedia (Generation VI to VIII): "Freeze-Dry has a 10% chance of freezing the target."
/// When the effect chance roll succeeds, the target is frozen, with the user as the origin.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EffectChanceSucceeds_TargetIsFrozen()
{
// Arrange
var (script, move, target, user, _) = CreateSecondaryEffectSetup(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var call = target.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "SetStatus");
await Assert.That(call).IsNotNull();
await Assert.That((StringKey)call!.GetArguments()[0]!).IsEqualTo(new StringKey("frozen"));
await Assert.That(ReferenceEquals(call.GetArguments()[1], user)).IsTrue();
}
/// <summary>
/// Bulbapedia (Generation VI to VIII): "Freeze-Dry has a 10% chance of freezing the target."
/// When the effect chance roll fails, no status may be set.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EffectChanceFails_TargetNotFrozen()
{
// Arrange
var (script, move, target, _, _) = CreateSecondaryEffectSetup(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation VI to VIII): "Freeze-Dry has a 10% chance of freezing the target."
/// The effect chance roll must be made with a 10% chance.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_RollsTenPercentEffectChance()
{
// Arrange
var (script, move, target, _, random) = CreateSecondaryEffectSetup(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var call = random.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "EffectChance");
await Assert.That(call).IsNotNull();
await Assert.That((float)call!.GetArguments()[0]!).IsEqualTo(10f);
}
/// <summary>
/// If the target has no <see cref="IPokemon.BattleData"/>, no effect chance can be rolled, so the
/// target may not be frozen.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NullBattleData_TargetNotFrozen()
{
// Arrange
var (script, move, target, _, _) = CreateSecondaryEffectSetup(true);
target.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse();
}
}

View File

@@ -0,0 +1,131 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="FreezeShock"/> move script.
/// Gen VII Bulbapedia behavior: "Freeze Shock deals damage and has a 30% chance of paralyzing the target."
/// Freeze Shock is a two-turn move: the generic charge-turn handling lives in the shared base charge move
/// class, so these tests only cover the concrete logic of <see cref="FreezeShock"/> itself (its paralysis
/// secondary effect and the charge volatile it creates).
/// </summary>
public class FreezeShockTests
{
/// <summary>
/// Creates a fully mocked test setup for Freeze Shock secondary effect tests.
/// </summary>
private static (FreezeShock script, IExecutingMove move, IPokemon target, IPokemon user, IBattleRandom random)
CreateTestSetup()
{
var script = new FreezeShock();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var battle = Substitute.For<IBattle>();
var random = Substitute.For<IBattleRandom>();
battle.Random.Returns(random);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
return (script, move, target, user, random);
}
/// <summary>
/// Bulbapedia: "Freeze Shock deals damage and has a 30% chance of paralyzing the target."
/// When the effect chance roll succeeds, the target is paralyzed by the user.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EffectChanceSucceeds_ParalyzesTarget()
{
// Arrange
var (script, move, target, user, random) = CreateTestSetup();
random.EffectChance(30, move, target, 0).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.Received(1).SetStatus(new StringKey("paralyzed"), user);
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsTrue();
}
/// <summary>
/// Bulbapedia: "a 30% chance of paralyzing the target."
/// When the effect chance roll fails, the target is not paralyzed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EffectChanceFails_DoesNotParalyzeTarget()
{
// Arrange
var (script, move, target, _, random) = CreateTestSetup();
random.EffectChance(30, move, target, 0).Returns(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse();
}
/// <summary>
/// Bulbapedia: "a 30% chance of paralyzing the target."
/// The paralysis roll is made with exactly a 30% chance.
/// </summary>
[Test]
public async Task OnSecondaryEffect_ParalysisRoll_UsesThirtyPercentChance()
{
// Arrange
var (script, move, target, _, random) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
random.Received(1).EffectChance(30, move, target, 0);
await Assert.That(random.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "EffectChance")).IsTrue();
}
/// <summary>
/// Technical test: outside of battle (no battle data on the target) the secondary effect does nothing
/// and does not throw.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHasNoBattleData_DoesNotParalyzeTarget()
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup();
target.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse();
}
/// <summary>
/// Bulbapedia: the user is "cloaked in a freezing light" on the charge turn and attacks on the following
/// turn. The concrete script provides the <see cref="RequireChargeEffect"/> volatile that forces the user
/// to execute Freeze Shock on the second turn.
/// </summary>
[Test]
public async Task CreateVolatile_ReturnsRequireChargeEffect()
{
// Arrange
var script = new FreezeShock();
var user = Substitute.For<IPokemon>();
// Act
var chargeEffect = script.CreateVolatile(user);
// Assert
await Assert.That(chargeEffect).IsNotNull();
}
}

View File

@@ -0,0 +1,106 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Frustration"/> move script.
/// Gen VII Bulbapedia behavior: "Frustration inflicts damage and has no secondary effect. The base power of
/// Frustration is dependent on the user's friendship. The lower the user's friendship is, the greater the
/// base power of Frustration." The power formula is Power = (255 Friendship) × 2 / 5, and from Generation
/// III to VII, "if calculations yielded zero, the move's power becomes 1 instead".
/// </summary>
public class FrustrationTests
{
/// <summary>
/// Creates a fully mocked test setup with a user that has the given friendship value.
/// </summary>
private static (Frustration script, IExecutingMove move, IPokemon target) CreateTestSetup(byte friendship)
{
var script = new Frustration();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var user = Substitute.For<IPokemon>();
user.Happiness.Returns(friendship);
move.User.Returns(user);
return (script, move, target);
}
/// <summary>
/// Bulbapedia: "The base power of Frustration is dependent on the user's friendship. The lower the
/// user's friendship is, the greater the base power of Frustration." The power formula is
/// Power = (255 Friendship) × 2 / 5, with integer truncation.
/// </summary>
[Test, Arguments((byte)0, (ushort)102), Arguments((byte)50, (ushort)82), Arguments((byte)100, (ushort)62),
Arguments((byte)128, (ushort)50), Arguments((byte)200, (ushort)22), Arguments((byte)250, (ushort)2)]
public async Task ChangeBasePower_FriendshipValue_MatchesFormula(byte friendship, ushort expectedPower)
{
// Arrange
var (script, move, target) = CreateTestSetup(friendship);
ushort basePower = 102;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expectedPower);
}
/// <summary>
/// Bulbapedia (Generations III to VII): the formula was adjusted so "if calculations yielded zero, the
/// move's power becomes 1 instead". Friendship values of 253 and above yield 0 from the formula, so the
/// power becomes 1.
/// </summary>
[Test, Arguments((byte)253), Arguments((byte)254), Arguments((byte)255)]
public async Task ChangeBasePower_FriendshipNearMaximum_PowerIsMinimumOne(byte friendship)
{
// Arrange
var (script, move, target) = CreateTestSetup(friendship);
ushort basePower = 102;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)1);
}
/// <summary>
/// Bulbapedia: "The lower the user's friendship is, the greater the base power of Frustration." — at 0
/// friendship the move reaches its maximum power of 102.
/// </summary>
[Test]
public async Task ChangeBasePower_ZeroFriendship_PowerIsMaximum102()
{
// Arrange
var (script, move, target) = CreateTestSetup(0);
ushort basePower = 102;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)102);
}
/// <summary>
/// The power is derived purely from the user's friendship: the incoming base power value does not
/// influence the result.
/// </summary>
[Test]
public async Task ChangeBasePower_DifferentIncomingBasePower_ResultDependsOnlyOnFriendship()
{
// Arrange
var (script, move, target) = CreateTestSetup(100);
ushort basePowerLow = 1;
ushort basePowerHigh = 250;
// Act
script.ChangeBasePower(move, target, 0, ref basePowerLow);
script.ChangeBasePower(move, target, 0, ref basePowerHigh);
// Assert
await Assert.That(basePowerLow).IsEqualTo((ushort)62);
await Assert.That(basePowerHigh).IsEqualTo((ushort)62);
}
}

View File

@@ -0,0 +1,173 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
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="FuryCutter"/> move script and its <see cref="FuryCutterEffect"/> volatile.
/// Gen VII Bulbapedia behavior: "Every time Fury Cutter hits the power will double", and (Generation VI
/// onwards) "Fury Cutter's base power increased even further from 20 to 40, meaning that it takes three
/// consecutive turns to reach the maximum power of 160."
/// </summary>
public class FuryCutterTests
{
/// <summary>
/// Creates a fully mocked test setup where the user's volatile scripts are a real
/// <see cref="ScriptSet"/>, so the <see cref="FuryCutterEffect"/> added by the move can be inspected.
/// </summary>
private static (FuryCutter script, IExecutingMove move, IPokemon target, IPokemon user, ScriptSet userVolatile)
CreateTestSetup()
{
var script = new FuryCutter();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var user = Substitute.For<IPokemon>();
var userVolatile = new ScriptSet(user);
user.Volatile.Returns(userVolatile);
user.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
move.User.Returns(user);
return (script, move, target, user, userVolatile);
}
/// <summary>
/// Simulates the given number of consecutive uses of Fury Cutter, feeding the Gen VII base power of 40
/// into each use, and returns the base power of the final use.
/// </summary>
private static ushort SimulateConsecutiveUses(FuryCutter script, IExecutingMove move, IPokemon target, int uses)
{
ushort basePower = 40;
for (var i = 0; i < uses; i++)
{
basePower = 40;
script.ChangeBasePower(move, target, 0, ref basePower);
}
return basePower;
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "Fury Cutter's base power increased even further from 20 to 40" —
/// on the first use there is no previous hit, so the base power is not modified.
/// </summary>
[Test]
public async Task ChangeBasePower_FirstUse_BasePowerUnchanged()
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup();
// Act
var basePower = SimulateConsecutiveUses(script, move, target, 1);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)40);
}
/// <summary>
/// Bulbapedia: the power doubles on consecutive hits — the first use attaches the
/// <see cref="FuryCutterEffect"/> volatile to the user so the next use is recognized as consecutive.
/// </summary>
[Test]
public async Task ChangeBasePower_FirstUse_AddsFuryCutterEffectToUser()
{
// Arrange
var (script, move, target, _, userVolatile) = CreateTestSetup();
// Act
SimulateConsecutiveUses(script, move, target, 1);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<FuryCutterEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "Every time Fury Cutter hits the power will double" — the second consecutive use has
/// power 80 and the third consecutive use reaches the maximum of 160.
/// </summary>
[Test, Arguments(2, (ushort)80), Arguments(3, (ushort)160)]
public async Task ChangeBasePower_ConsecutiveUses_PowerDoublesEachTurn(int uses, ushort expectedPower)
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup();
// Act
var basePower = SimulateConsecutiveUses(script, move, target, uses);
// Assert
await Assert.That(basePower).IsEqualTo(expectedPower);
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "it takes three consecutive turns to reach the maximum power of
/// 160" — uses beyond the third consecutive turn stay capped at 160 and do not keep doubling.
/// </summary>
[Test, Arguments(4), Arguments(5), Arguments(6)]
public async Task ChangeBasePower_BeyondThirdConsecutiveUse_PowerCappedAt160(int uses)
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup();
// Act
var basePower = SimulateConsecutiveUses(script, move, target, uses);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)160);
}
/// <summary>
/// Bulbapedia (Generation V onwards): "If another move is selected, its power resets to normal." — when
/// the user executes a different move, the <see cref="FuryCutterEffect"/> removes itself, resetting the
/// doubling progression.
/// </summary>
[Test]
public async Task OnBeforeMove_DifferentMoveUsed_EffectRemovesItself()
{
// Arrange
var user = Substitute.For<IPokemon>();
user.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
var userVolatile = new ScriptSet(user);
var effect = new FuryCutterEffect();
userVolatile.Add(effect);
var otherMove = Substitute.For<IExecutingMove>();
var otherMoveData = Substitute.For<IMoveData>();
otherMoveData.Name.Returns(new StringKey("tackle"));
otherMove.UseMove.Returns(otherMoveData);
// Act
effect.OnBeforeMove(otherMove);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<FuryCutterEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: the doubling continues on "consecutive turns" — when Fury Cutter is used again, the
/// <see cref="FuryCutterEffect"/> stays on the user so the progression continues.
/// </summary>
[Test]
public async Task OnBeforeMove_FuryCutterUsedAgain_EffectPersists()
{
// Arrange
var user = Substitute.For<IPokemon>();
user.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
var userVolatile = new ScriptSet(user);
var effect = new FuryCutterEffect();
userVolatile.Add(effect);
var furyCutterMove = Substitute.For<IExecutingMove>();
var furyCutterMoveData = Substitute.For<IMoveData>();
furyCutterMoveData.Name.Returns(new StringKey("fury_cutter"));
furyCutterMove.UseMove.Returns(furyCutterMoveData);
// Act
effect.OnBeforeMove(furyCutterMove);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<FuryCutterEffect>())).IsTrue();
}
}

View File

@@ -0,0 +1,253 @@
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="FusionBolt"/> move script.
/// Gen VII Bulbapedia behavior: "Fusion Bolt inflicts damage. The base power of Fusion Bolt will double if
/// any Pokémon has successfully used Fusion Flare previously in the same turn, with no intervening moves in
/// between, except perhaps for failed moves."
/// </summary>
public class FusionBoltTests
{
/// <summary>
/// Creates a concrete <see cref="MoveChoice"/> whose chosen move has the given name. Concrete choices
/// are required because the script filters the turn's choices by the <see cref="MoveChoice"/> type.
/// </summary>
private static MoveChoice CreateMoveChoice(string moveName, bool failed = false)
{
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new StringKey(moveName));
moveData.SecondaryEffect.Returns((ISecondaryEffect?)null);
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
var user = Substitute.For<IPokemon>();
var choice = new MoveChoice(user, learnedMove, 0, 0);
if (failed)
choice.Fail();
return choice;
}
/// <summary>
/// Creates a fully mocked test setup where the current turn consists of the given choices, executed in
/// order, and the script is evaluating the given executing choice.
/// </summary>
private static (FusionBolt script, IExecutingMove move, IPokemon target) CreateTestSetup(
IMoveChoice executingChoice, params ITurnChoice[] currentTurnChoices)
{
var script = new FusionBolt();
var move = Substitute.For<IExecutingMove>();
move.MoveChoice.Returns(executingChoice);
var battle = Substitute.For<IBattle>();
battle.PreviousTurnChoices.Returns(new List<IReadOnlyList<ITurnChoice>> { currentTurnChoices });
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
return (script, move, target);
}
/// <summary>
/// Bulbapedia: "The base power of Fusion Bolt will double if any Pokémon has successfully used Fusion
/// Flare previously in the same turn".
/// </summary>
[Test]
public async Task ChangeDamageModifier_FusionFlareUsedEarlierInTurn_ModifierDoubles()
{
// Arrange
var boltChoice = CreateMoveChoice("fusion_bolt");
var flareChoice = CreateMoveChoice("fusion_flare");
var (script, move, target) = CreateTestSetup(boltChoice, flareChoice, boltChoice);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(2f);
}
/// <summary>
/// Bulbapedia: the power only doubles "if any Pokémon has successfully used Fusion Flare previously in
/// the same turn" — with no Fusion Flare used this turn, the modifier stays unchanged.
/// </summary>
[Test]
public async Task ChangeDamageModifier_NoFusionFlareInTurn_ModifierUnchanged()
{
// Arrange
var boltChoice = CreateMoveChoice("fusion_bolt");
var tackleChoice = CreateMoveChoice("tackle");
var (script, move, target) = CreateTestSetup(boltChoice, tackleChoice, boltChoice);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1f);
}
/// <summary>
/// Bulbapedia: Fusion Flare must have been used "previously in the same turn" — a Fusion Flare that
/// executes after this Fusion Bolt does not boost it.
/// </summary>
[Test]
public async Task ChangeDamageModifier_FusionFlareLaterInTurn_ModifierUnchanged()
{
// Arrange
var boltChoice = CreateMoveChoice("fusion_bolt");
var flareChoice = CreateMoveChoice("fusion_flare");
var (script, move, target) = CreateTestSetup(boltChoice, boltChoice, flareChoice);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1f);
}
/// <summary>
/// Bulbapedia: the boost only applies "in the same turn" — a Fusion Flare used on the previous turn does
/// not double this turn's Fusion Bolt.
/// </summary>
[Test]
public async Task ChangeDamageModifier_FusionFlareOnPreviousTurn_ModifierUnchanged()
{
// Arrange
var boltChoice = CreateMoveChoice("fusion_bolt");
var flareChoice = CreateMoveChoice("fusion_flare");
var script = new FusionBolt();
var move = Substitute.For<IExecutingMove>();
move.MoveChoice.Returns(boltChoice);
var battle = Substitute.For<IBattle>();
battle.PreviousTurnChoices.Returns(new List<IReadOnlyList<ITurnChoice>>
{
new ITurnChoice[] { flareChoice },
new ITurnChoice[] { boltChoice },
});
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1f);
}
/// <summary>
/// Bulbapedia: the boost requires Fusion Flare "with no intervening moves in between" — another
/// successful move executed between Fusion Flare and Fusion Bolt breaks the combination.
/// </summary>
[Test]
public async Task ChangeDamageModifier_SuccessfulMoveBetweenFlareAndBolt_ModifierUnchanged()
{
// Arrange
var boltChoice = CreateMoveChoice("fusion_bolt");
var flareChoice = CreateMoveChoice("fusion_flare");
var tackleChoice = CreateMoveChoice("tackle");
var (script, move, target) = CreateTestSetup(boltChoice, flareChoice, tackleChoice, boltChoice);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1f);
}
/// <summary>
/// Bulbapedia: "with no intervening moves in between, except perhaps for failed moves" — a failed move
/// between Fusion Flare and Fusion Bolt does not break the combination.
/// </summary>
[Test]
public async Task ChangeDamageModifier_FailedMoveBetweenFlareAndBolt_ModifierStillDoubles()
{
// Arrange
var boltChoice = CreateMoveChoice("fusion_bolt");
var flareChoice = CreateMoveChoice("fusion_flare");
var failedTackleChoice = CreateMoveChoice("tackle", true);
var (script, move, target) = CreateTestSetup(boltChoice, flareChoice, failedTackleChoice, boltChoice);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(2f);
}
/// <summary>
/// Bulbapedia: the boost requires that a Pokémon "has successfully used Fusion Flare" — a Fusion Flare
/// that failed does not double the power.
/// </summary>
[Test]
public async Task ChangeDamageModifier_FusionFlareFailed_ModifierUnchanged()
{
// Arrange
var boltChoice = CreateMoveChoice("fusion_bolt");
var failedFlareChoice = CreateMoveChoice("fusion_flare", true);
var (script, move, target) = CreateTestSetup(boltChoice, failedFlareChoice, boltChoice);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1f);
}
/// <summary>
/// Bulbapedia: only Fusion Flare boosts Fusion Bolt — another Fusion Bolt used earlier in the turn does
/// not double the power.
/// </summary>
[Test]
public async Task ChangeDamageModifier_AnotherFusionBoltUsedEarlier_ModifierUnchanged()
{
// Arrange
var boltChoice = CreateMoveChoice("fusion_bolt");
var earlierBoltChoice = CreateMoveChoice("fusion_bolt");
var (script, move, target) = CreateTestSetup(boltChoice, earlierBoltChoice, boltChoice);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1f);
}
/// <summary>
/// Technical test: outside of battle (no battle data on the target) the modifier is left unchanged and
/// the hook does not throw.
/// </summary>
[Test]
public async Task ChangeDamageModifier_TargetHasNoBattleData_ModifierUnchanged()
{
// Arrange
var script = new FusionBolt();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.BattleData.Returns((IPokemonBattleData?)null);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1f);
}
}

View File

@@ -0,0 +1,253 @@
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="FusionFlare"/> move script.
/// Gen VII Bulbapedia behavior: "Fusion Flare inflicts damage. The base power of Fusion Flare will double
/// if any Pokémon has successfully used Fusion Bolt previously in the same turn, with no intervening moves
/// in between, except perhaps for failed moves."
/// </summary>
public class FusionFlareTests
{
/// <summary>
/// Creates a concrete <see cref="MoveChoice"/> whose chosen move has the given name. Concrete choices
/// are required because the script filters the turn's choices by the <see cref="MoveChoice"/> type.
/// </summary>
private static MoveChoice CreateMoveChoice(string moveName, bool failed = false)
{
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new StringKey(moveName));
moveData.SecondaryEffect.Returns((ISecondaryEffect?)null);
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
var user = Substitute.For<IPokemon>();
var choice = new MoveChoice(user, learnedMove, 0, 0);
if (failed)
choice.Fail();
return choice;
}
/// <summary>
/// Creates a fully mocked test setup where the current turn consists of the given choices, executed in
/// order, and the script is evaluating the given executing choice.
/// </summary>
private static (FusionFlare script, IExecutingMove move, IPokemon target) CreateTestSetup(
IMoveChoice executingChoice, params ITurnChoice[] currentTurnChoices)
{
var script = new FusionFlare();
var move = Substitute.For<IExecutingMove>();
move.MoveChoice.Returns(executingChoice);
var battle = Substitute.For<IBattle>();
battle.PreviousTurnChoices.Returns(new List<IReadOnlyList<ITurnChoice>> { currentTurnChoices });
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
return (script, move, target);
}
/// <summary>
/// Bulbapedia: "The base power of Fusion Flare will double if any Pokémon has successfully used Fusion
/// Bolt previously in the same turn".
/// </summary>
[Test]
public async Task ChangeDamageModifier_FusionBoltUsedEarlierInTurn_ModifierDoubles()
{
// Arrange
var flareChoice = CreateMoveChoice("fusion_flare");
var boltChoice = CreateMoveChoice("fusion_bolt");
var (script, move, target) = CreateTestSetup(flareChoice, boltChoice, flareChoice);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(2f);
}
/// <summary>
/// Bulbapedia: the power only doubles "if any Pokémon has successfully used Fusion Bolt previously in
/// the same turn" — with no Fusion Bolt used this turn, the modifier stays unchanged.
/// </summary>
[Test]
public async Task ChangeDamageModifier_NoFusionBoltInTurn_ModifierUnchanged()
{
// Arrange
var flareChoice = CreateMoveChoice("fusion_flare");
var tackleChoice = CreateMoveChoice("tackle");
var (script, move, target) = CreateTestSetup(flareChoice, tackleChoice, flareChoice);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1f);
}
/// <summary>
/// Bulbapedia: Fusion Bolt must have been used "previously in the same turn" — a Fusion Bolt that
/// executes after this Fusion Flare does not boost it.
/// </summary>
[Test]
public async Task ChangeDamageModifier_FusionBoltLaterInTurn_ModifierUnchanged()
{
// Arrange
var flareChoice = CreateMoveChoice("fusion_flare");
var boltChoice = CreateMoveChoice("fusion_bolt");
var (script, move, target) = CreateTestSetup(flareChoice, flareChoice, boltChoice);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1f);
}
/// <summary>
/// Bulbapedia: the boost only applies "in the same turn" — a Fusion Bolt used on the previous turn does
/// not double this turn's Fusion Flare.
/// </summary>
[Test]
public async Task ChangeDamageModifier_FusionBoltOnPreviousTurn_ModifierUnchanged()
{
// Arrange
var flareChoice = CreateMoveChoice("fusion_flare");
var boltChoice = CreateMoveChoice("fusion_bolt");
var script = new FusionFlare();
var move = Substitute.For<IExecutingMove>();
move.MoveChoice.Returns(flareChoice);
var battle = Substitute.For<IBattle>();
battle.PreviousTurnChoices.Returns(new List<IReadOnlyList<ITurnChoice>>
{
new ITurnChoice[] { boltChoice },
new ITurnChoice[] { flareChoice },
});
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1f);
}
/// <summary>
/// Bulbapedia: the boost requires Fusion Bolt "with no intervening moves in between" — another
/// successful move executed between Fusion Bolt and Fusion Flare breaks the combination.
/// </summary>
[Test]
public async Task ChangeDamageModifier_SuccessfulMoveBetweenBoltAndFlare_ModifierUnchanged()
{
// Arrange
var flareChoice = CreateMoveChoice("fusion_flare");
var boltChoice = CreateMoveChoice("fusion_bolt");
var tackleChoice = CreateMoveChoice("tackle");
var (script, move, target) = CreateTestSetup(flareChoice, boltChoice, tackleChoice, flareChoice);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1f);
}
/// <summary>
/// Bulbapedia: "with no intervening moves in between, except perhaps for failed moves" — a failed move
/// between Fusion Bolt and Fusion Flare does not break the combination.
/// </summary>
[Test]
public async Task ChangeDamageModifier_FailedMoveBetweenBoltAndFlare_ModifierStillDoubles()
{
// Arrange
var flareChoice = CreateMoveChoice("fusion_flare");
var boltChoice = CreateMoveChoice("fusion_bolt");
var failedTackleChoice = CreateMoveChoice("tackle", true);
var (script, move, target) = CreateTestSetup(flareChoice, boltChoice, failedTackleChoice, flareChoice);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(2f);
}
/// <summary>
/// Bulbapedia: the boost requires that a Pokémon "has successfully used Fusion Bolt" — a Fusion Bolt
/// that failed does not double the power.
/// </summary>
[Test]
public async Task ChangeDamageModifier_FusionBoltFailed_ModifierUnchanged()
{
// Arrange
var flareChoice = CreateMoveChoice("fusion_flare");
var failedBoltChoice = CreateMoveChoice("fusion_bolt", true);
var (script, move, target) = CreateTestSetup(flareChoice, failedBoltChoice, flareChoice);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1f);
}
/// <summary>
/// Bulbapedia: only Fusion Bolt boosts Fusion Flare — another Fusion Flare used earlier in the turn
/// does not double the power.
/// </summary>
[Test]
public async Task ChangeDamageModifier_AnotherFusionFlareUsedEarlier_ModifierUnchanged()
{
// Arrange
var flareChoice = CreateMoveChoice("fusion_flare");
var earlierFlareChoice = CreateMoveChoice("fusion_flare");
var (script, move, target) = CreateTestSetup(flareChoice, earlierFlareChoice, flareChoice);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1f);
}
/// <summary>
/// Technical test: outside of battle (no battle data on the target) the modifier is left unchanged and
/// the hook does not throw.
/// </summary>
[Test]
public async Task ChangeDamageModifier_TargetHasNoBattleData_ModifierUnchanged()
{
// Arrange
var script = new FusionFlare();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.BattleData.Returns((IPokemonBattleData?)null);
var modifier = 1f;
// Act
script.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1f);
}
}

View File

@@ -0,0 +1,360 @@
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();
}
}

View File

@@ -0,0 +1,57 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="GastroAcid"/> move script.
/// Gen VII Bulbapedia behavior: "Gastro Acid suppresses the target's Ability while it remains in battle."
/// </summary>
/// <remarks>
/// Bulbapedia also lists Abilities the move fails against (Multitype, Stance Change, ...); that check is
/// implemented engine-side through <see cref="PkmnLib.Static.Species.IAbility.CanBeChanged"/> inside
/// <see cref="IPokemon.SuppressAbility"/>, so it is not the responsibility of this script.
/// </remarks>
public class GastroAcidTests
{
/// <summary>
/// Bulbapedia: "Gastro Acid suppresses the target's Ability while it remains in battle."
/// The script requests the suppression on the target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_SuppressesTargetAbility()
{
// Arrange
var script = new GastroAcid();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.Received(1).SuppressAbility();
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SuppressAbility")).IsTrue();
}
/// <summary>
/// Bulbapedia: the suppression applies to "the target's Ability" — the user's own Ability is left
/// untouched.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_DoesNotSuppressUserAbility()
{
// Arrange
var script = new GastroAcid();
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
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SuppressAbility")).IsFalse();
}
}

View File

@@ -0,0 +1,229 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Species;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="GearUp"/> move script.
/// Gen VII Bulbapedia behavior: "Gear Up raises the Attack and Special Attack stats of allied Pokémon
/// (including the user) with the Ability Plus or Minus by one stage each."
/// </summary>
public class GearUpTests
{
/// <summary>
/// Creates a fully mocked test setup for Gear Up tests. The user is on side 0; the given side lists
/// are installed as the battle's sides.
/// </summary>
private static (GearUp script, IExecutingMove move, IPokemon user) CreateTestSetup(
params IReadOnlyList<IPokemon?>[] sidePokemon)
{
var script = new GearUp();
var move = Substitute.For<IExecutingMove>();
var battle = Substitute.For<IBattle>();
var sides = sidePokemon.Select(pokemon =>
{
var side = Substitute.For<IBattleSide>();
side.Pokemon.Returns(pokemon);
return side;
}).ToArray();
battle.Sides.Returns(sides);
var user = Substitute.For<IPokemon>();
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
battleData.SideIndex.Returns((byte)0);
user.BattleData.Returns(battleData);
move.User.Returns(user);
return (script, move, user);
}
/// <summary>
/// Creates a mocked Pokémon whose <see cref="IPokemon.ActiveAbility"/> has the given name, or no
/// ability at all when <paramref name="abilityName"/> is null.
/// </summary>
private static IPokemon CreatePokemon(string? abilityName)
{
var pokemon = Substitute.For<IPokemon>();
if (abilityName != null)
{
var ability = Substitute.For<IAbility>();
ability.Name.Returns(new StringKey(abilityName));
pokemon.ActiveAbility.Returns(ability);
}
else
{
pokemon.ActiveAbility.Returns((IAbility?)null);
}
return pokemon;
}
/// <summary>
/// Helper to extract the arguments of the received ChangeStatBoost call for the given statistic, or
/// null when that stat was not boosted. Received-call inspection is used instead of NSubstitute
/// argument matchers because the trailing EventBatchId parameter cannot be bound by <c>Arg.Any</c>.
/// </summary>
private static object?[]? GetStatBoostArgs(IPokemon pokemon, Statistic stat) =>
pokemon.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost").Select(c => c.GetArguments())
.FirstOrDefault(args => (Statistic)args[0]! == stat);
/// <summary>
/// Bulbapedia: "Gear Up raises the Attack and Special Attack stats of allied Pokémon ... with the
/// Ability Plus ... by one stage each." An ally's boost is not self-inflicted.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AllyWithPlus_AttackAndSpecialAttackRaisedByOneStage()
{
// Arrange
var ally = CreatePokemon("plus");
var (script, move, _) = CreateTestSetup(new List<IPokemon?> { ally });
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
var attackArgs = GetStatBoostArgs(ally, Statistic.Attack);
var specialAttackArgs = GetStatBoostArgs(ally, Statistic.SpecialAttack);
await Assert.That(attackArgs).IsNotNull();
await Assert.That((sbyte)attackArgs![1]!).IsEqualTo((sbyte)1);
await Assert.That((bool)attackArgs[2]!).IsFalse(); // not self-inflicted
await Assert.That(specialAttackArgs).IsNotNull();
await Assert.That((sbyte)specialAttackArgs![1]!).IsEqualTo((sbyte)1);
}
/// <summary>
/// Bulbapedia: the boost also applies to allies "with the Ability ... Minus".
/// </summary>
[Test]
public async Task OnSecondaryEffect_AllyWithMinus_AttackAndSpecialAttackRaisedByOneStage()
{
// Arrange
var ally = CreatePokemon("minus");
var (script, move, _) = CreateTestSetup(new List<IPokemon?> { ally });
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(GetStatBoostArgs(ally, Statistic.Attack)).IsNotNull();
await Assert.That(GetStatBoostArgs(ally, Statistic.SpecialAttack)).IsNotNull();
}
/// <summary>
/// Bulbapedia: the boost applies to allied Pokémon "including the user" — a user with Plus boosts
/// itself, and that boost is self-inflicted.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserHasPlus_UserBoostIsSelfInflicted()
{
// Arrange
var (script, move, user) = CreateTestSetup();
var ability = Substitute.For<IAbility>();
ability.Name.Returns(new StringKey("plus"));
user.ActiveAbility.Returns(ability);
var side = Substitute.For<IBattleSide>();
side.Pokemon.Returns(new List<IPokemon?> { user });
user.BattleData!.Battle.Sides.Returns(new[] { side });
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
var attackArgs = GetStatBoostArgs(user, Statistic.Attack);
await Assert.That(attackArgs).IsNotNull();
await Assert.That((bool)attackArgs![2]!).IsTrue(); // self-inflicted for the user itself
}
/// <summary>
/// Bulbapedia: only allies "with the Ability Plus or Minus" are raised — an ally with a different
/// Ability is unaffected.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AllyWithOtherAbility_NotBoosted()
{
// Arrange
var ally = CreatePokemon("levitate");
var (script, move, _) = CreateTestSetup(new List<IPokemon?> { ally });
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(ally.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
}
/// <summary>
/// Bulbapedia: the boost applies to "allied Pokémon" — an opposing Pokémon with Plus is not raised.
/// </summary>
[Test]
public async Task OnSecondaryEffect_OpponentWithPlus_NotBoosted()
{
// Arrange
var opponent = CreatePokemon("plus");
var (script, move, _) = CreateTestSetup(new List<IPokemon?>(), new List<IPokemon?> { opponent });
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(opponent.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
}
/// <summary>
/// Technical test: an ally without an active Ability is skipped without throwing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AllyWithoutAbility_NotBoosted()
{
// Arrange
var ally = CreatePokemon(null);
var (script, move, _) = CreateTestSetup(new List<IPokemon?> { ally });
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(ally.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
}
/// <summary>
/// Technical test: empty (null) slots on the user's side are skipped without throwing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EmptyPokemonSlot_IsSkipped()
{
// Arrange
var ally = CreatePokemon("plus");
var (script, move, _) = CreateTestSetup(new List<IPokemon?> { null, ally });
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(GetStatBoostArgs(ally, Statistic.Attack)).IsNotNull();
}
/// <summary>
/// Technical test: when the user has no <see cref="IPokemon.BattleData"/> (it is not in a battle),
/// the script does nothing instead of throwing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NullBattleData_DoesNothing()
{
// Arrange
var ally = CreatePokemon("plus");
var (script, move, user) = CreateTestSetup(new List<IPokemon?> { ally });
user.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(ally.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
}
}

View File

@@ -0,0 +1,65 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PsychicTerrainScript = PkmnLib.Plugin.Gen7.Scripts.Terrain.PsychicTerrain;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="GenesisSupernova"/> move script.
/// Gen VII Bulbapedia behavior: "Genesis Supernova deals damage and causes the battlefield to become
/// Psychic Terrain for five turns."
/// </summary>
public class GenesisSupernovaTests
{
private static (GenesisSupernova script, IExecutingMove move, IBattle battle) CreateTestSetup()
{
var script = new GenesisSupernova();
var move = Substitute.For<IExecutingMove>();
var battle = Substitute.For<IBattle>();
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, battle);
}
/// <summary>
/// Bulbapedia: Genesis Supernova "causes the battlefield to become Psychic Terrain" — the move sets
/// the battle's terrain to the <see cref="PsychicTerrainScript"/> terrain script.
/// </summary>
[Test]
public void OnSecondaryEffect_SetsPsychicTerrainOnBattle()
{
// Arrange
var (script, move, battle) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
battle.Received(1).SetTerrain(ScriptUtils.ResolveName<PsychicTerrainScript>());
}
/// <summary>
/// Technical test: without battle data on the user (outside of battle) the effect does nothing and
/// does not throw.
/// </summary>
[Test]
public void OnSecondaryEffect_UserHasNoBattleData_DoesNotSetTerrain()
{
// Arrange
var (script, move, battle) = CreateTestSetup();
move.User.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
battle.DidNotReceiveWithAnyArgs().SetTerrain(default);
}
}

View File

@@ -0,0 +1,144 @@
using PkmnLib.Dynamic.Events;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
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="Geomancy"/> move script.
/// Gen VII Bulbapedia behavior: "The user charges the move on the first turn. On the second turn, the
/// user's Special Attack, Special Defense, and Speed increase by two stages."
/// </summary>
public class GeomancyTests
{
private static (Geomancy script, IExecutingMove move, IPokemon user, ScriptSet userVolatile) CreateTestSetup()
{
var script = new Geomancy();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
// Use a real script set so the charge volatile added by Geomancy can be inspected afterwards.
var userVolatile = new ScriptSet(user);
user.Volatile.Returns(userVolatile);
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
move.User.Returns(user);
// BaseChargeMove.PreventMove runs the BypassChargeMove custom trigger hook on the executing move,
// so the move itself also needs a script iterator.
move.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var battle = Substitute.For<IBattle>();
battle.EventHook.Returns(new EventHook());
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
user.BattleData.Returns(battleData);
return (script, move, user, userVolatile);
}
/// <summary>
/// Helper to extract the arguments of the received ChangeStatBoost call for the given statistic, or
/// null when that stat was not boosted. Received-call inspection is used instead of NSubstitute
/// argument matchers because the trailing <see cref="EventBatchId"/> parameter cannot be bound by
/// <c>Arg.Any</c>.
/// </summary>
private static object?[]? GetStatBoostArgs(IPokemon pokemon, Statistic stat) =>
pokemon.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost").Select(c => c.GetArguments())
.FirstOrDefault(args => (Statistic)args[0]! == stat);
/// <summary>
/// Bulbapedia: "The user charges the move on the first turn." — on first use the move is prevented
/// from executing.
/// </summary>
[Test]
public async Task PreventMove_FirstUse_PreventsMoveForChargeTurn()
{
// Arrange
var (script, move, _, _) = CreateTestSetup();
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "The user charges the move on the first turn." — the charge turn attaches the
/// <see cref="RequireChargeEffect"/> volatile to the user so the move executes on the next turn.
/// </summary>
[Test]
public async Task PreventMove_FirstUse_AddsChargeVolatileToUser()
{
// Arrange
var (script, move, _, userVolatile) = CreateTestSetup();
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<RequireChargeEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "On the second turn" the move executes — when the charge volatile is already present,
/// the move is not prevented again.
/// </summary>
[Test]
public async Task PreventMove_SecondTurn_DoesNotPreventMove()
{
// Arrange
var (script, move, user, userVolatile) = CreateTestSetup();
userVolatile.Add(script.CreateVolatile(user));
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia: on the second turn "the user's Special Attack, Special Defense, and Speed increase by
/// two stages." The boosts are self-inflicted.
/// </summary>
[Test, Arguments(Statistic.SpecialAttack), Arguments(Statistic.SpecialDefense), Arguments(Statistic.Speed)]
public async Task OnSecondaryEffect_Always_RaisesStatByTwoStages(Statistic stat)
{
// Arrange
var (script, move, user, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
var args = GetStatBoostArgs(user, stat);
await Assert.That(args).IsNotNull();
await Assert.That((sbyte)args![1]!).IsEqualTo((sbyte)2);
await Assert.That((bool)args[2]!).IsTrue(); // self-inflicted
await Assert.That((bool)args[3]!).IsFalse(); // not forced
}
/// <summary>
/// Bulbapedia: only "Special Attack, Special Defense, and Speed" are raised — Attack and Defense stay
/// untouched.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_DoesNotRaiseAttackOrDefense()
{
// Arrange
var (script, move, user, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(GetStatBoostArgs(user, Statistic.Attack)).IsNull();
await Assert.That(GetStatBoostArgs(user, Statistic.Defense)).IsNull();
}
}

View File

@@ -0,0 +1,58 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="GrassKnot"/> move script.
/// Gen VII Bulbapedia behavior: "Grass Knot deals damage scaled by the target's weight rather than using a
/// fixed base power", with cutoffs at 10, 25, 50, 100 and 200 kilograms.
/// </summary>
public class GrassKnotTests
{
/// <summary>
/// Bulbapedia weight/power table: 0.19.9 kg → 20, 10.024.9 kg → 40, 25.049.9 kg → 60,
/// 50.099.9 kg → 80, 100.0199.9 kg → 100, 200.0 kg or more → 120. Each cutoff is tested on both
/// sides of the boundary.
/// </summary>
[Test, Arguments(0.1f, (ushort)20), Arguments(9.9f, (ushort)20), Arguments(10.0f, (ushort)40),
Arguments(24.9f, (ushort)40), Arguments(25.0f, (ushort)60), Arguments(49.9f, (ushort)60),
Arguments(50.0f, (ushort)80), Arguments(99.9f, (ushort)80), Arguments(100.0f, (ushort)100),
Arguments(199.9f, (ushort)100), Arguments(200.0f, (ushort)120), Arguments(999.9f, (ushort)120)]
public async Task ChangeBasePower_TargetWeight_SetsBasePowerFromWeightTable(float weightInKg, ushort expectedPower)
{
// Arrange
var script = new GrassKnot();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.WeightInKg.Returns(weightInKg);
ushort basePower = 1;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expectedPower);
}
/// <summary>
/// Bulbapedia: the base power depends only on the target's weight — the incoming base power value is
/// fully replaced rather than modified.
/// </summary>
[Test]
public async Task ChangeBasePower_HighIncomingBasePower_IsReplacedByWeightTableValue()
{
// Arrange
var script = new GrassKnot();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.WeightInKg.Returns(5f);
ushort basePower = 250;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)20);
}
}

View File

@@ -0,0 +1,249 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.MoveVolatile;
using PkmnLib.Static.Moves;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="GrassPledge"/> move script.
/// Bulbapedia: "When allies use Grass Pledge paired with Fire or Water Pledge simultaneously, the
/// first-moving ally skips their turn while the second-moving ally executes a combined attack with 150
/// power" and a unique field effect.
/// </summary>
public class GrassPledgeTests
{
/// <summary>
/// Creates a move choice for a Pokémon on the given side, using a move with the given name.
/// The choice gets a real <see cref="ScriptSet"/> as its volatile set, so combination markers can be
/// added to and read from it.
/// </summary>
private static IMoveChoice CreateQueuedChoice(string moveName, byte sideIndex)
{
var choice = Substitute.For<IMoveChoice>();
choice.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var pokemon = Substitute.For<IPokemon>();
var battleData = Substitute.For<IPokemonBattleData>();
battleData.SideIndex.Returns(sideIndex);
pokemon.BattleData.Returns(battleData);
choice.User.Returns(pokemon);
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new StringKey(moveName));
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
choice.ChosenMove.Returns(learnedMove);
var choiceVolatile = new ScriptSet(choice);
choice.Volatile.Returns(choiceVolatile);
return choice;
}
/// <summary>
/// Creates a fully mocked test setup for Grass Pledge tests. The user is on side 0.
/// </summary>
private static (GrassPledge script, IExecutingMove move, IMoveChoice ownChoice) CreateTestSetup(
BattleChoiceQueue? queue)
{
var script = new GrassPledge();
var move = Substitute.For<IExecutingMove>();
var battle = Substitute.For<IBattle>();
battle.ChoiceQueue.Returns(queue);
move.Battle.Returns(battle);
var user = Substitute.For<IPokemon>();
var userBattleData = Substitute.For<IPokemonBattleData>();
userBattleData.SideIndex.Returns((byte)0);
user.BattleData.Returns(userBattleData);
move.User.Returns(user);
var ownChoice = CreateQueuedChoice("grass_pledge", 0);
move.MoveChoice.Returns(ownChoice);
return (script, move, ownChoice);
}
/// <summary>
/// Bulbapedia: "the first-moving ally skips their turn". An ally on the same side still has Water
/// Pledge queued, so this Grass Pledge is stopped.
/// </summary>
[Test]
public async Task StopBeforeMove_AllyWaterPledgeQueued_StopsMove()
{
// Arrange
var allyChoice = CreateQueuedChoice("water_pledge", 0);
var (script, move, _) = CreateTestSetup(new BattleChoiceQueue([allyChoice]));
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsTrue();
}
/// <summary>
/// Bulbapedia: Grass Pledge combined with Water Pledge "produces a swamp on the target's side of the
/// field for four turns". The queued ally choice is marked with the <see cref="GrassWaterPledgeMove"/>
/// volatile that implements the combined move.
/// </summary>
[Test]
public async Task StopBeforeMove_AllyWaterPledgeQueued_MarksAllyChoiceAsCombinedGrassWaterPledge()
{
// Arrange
var allyChoice = CreateQueuedChoice("water_pledge", 0);
var (script, move, _) = CreateTestSetup(new BattleChoiceQueue([allyChoice]));
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(allyChoice.Volatile.Contains<GrassWaterPledgeMove>()).IsTrue();
}
/// <summary>
/// Bulbapedia: "the first-moving ally skips their turn". An ally on the same side still has Fire
/// Pledge queued, so this Grass Pledge is stopped.
/// </summary>
[Test]
public async Task StopBeforeMove_AllyFirePledgeQueued_StopsMove()
{
// Arrange
var allyChoice = CreateQueuedChoice("fire_pledge", 0);
var (script, move, _) = CreateTestSetup(new BattleChoiceQueue([allyChoice]));
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsTrue();
}
/// <summary>
/// Bulbapedia: Grass Pledge combined with Fire Pledge "creates a sea of fire on the target's side of
/// the field for four turns". The queued ally choice is marked with the
/// <see cref="FireGrassPledgeMove"/> volatile that implements the combined move.
/// </summary>
[Test]
public async Task StopBeforeMove_AllyFirePledgeQueued_MarksAllyChoiceAsCombinedFireGrassPledge()
{
// Arrange
var allyChoice = CreateQueuedChoice("fire_pledge", 0);
var (script, move, _) = CreateTestSetup(new BattleChoiceQueue([allyChoice]));
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(allyChoice.Volatile.Contains<FireGrassPledgeMove>()).IsTrue();
}
/// <summary>
/// Bulbapedia: "When used alone, it deals damage" — no Pledge move is queued by an ally, so Grass
/// Pledge executes normally.
/// </summary>
[Test]
public async Task StopBeforeMove_NoPledgeMoveQueued_DoesNotStop()
{
// Arrange
var allyChoice = CreateQueuedChoice("tackle", 0);
var (script, move, _) = CreateTestSetup(new BattleChoiceQueue([allyChoice]));
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
}
/// <summary>
/// Bulbapedia: the combination only happens "when allies use" the paired Pledge moves — a Pledge move
/// queued by an opponent does not combine.
/// </summary>
[Test]
public async Task StopBeforeMove_OpponentWaterPledgeQueued_DoesNotStop()
{
// Arrange
var opponentChoice = CreateQueuedChoice("water_pledge", 1);
var (script, move, _) = CreateTestSetup(new BattleChoiceQueue([opponentChoice]));
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
await Assert.That(opponentChoice.Volatile.Contains<GrassWaterPledgeMove>()).IsFalse();
}
/// <summary>
/// Bulbapedia: "the second-moving ally executes a combined attack with 150 power". When this Grass
/// Pledge choice is itself the combined move of Fire Pledge and Grass Pledge (marked with
/// <see cref="FireGrassPledgeMove"/>), it must execute instead of deferring again.
/// </summary>
[Test]
public async Task StopBeforeMove_ChoiceIsCombinedFireGrassPledge_DoesNotStop()
{
// Arrange
var allyChoice = CreateQueuedChoice("fire_pledge", 0);
var (script, move, ownChoice) = CreateTestSetup(new BattleChoiceQueue([allyChoice]));
ownChoice.Volatile.Add(new FireGrassPledgeMove());
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
await Assert.That(allyChoice.Volatile.Contains<FireGrassPledgeMove>()).IsFalse();
}
/// <summary>
/// Bulbapedia: "the second-moving ally executes a combined attack with 150 power". When this choice is
/// the combined move of Grass Pledge and Water Pledge (marked with <see cref="GrassWaterPledgeMove"/>),
/// it must execute instead of deferring again.
/// </summary>
[Test]
public async Task StopBeforeMove_ChoiceIsCombinedGrassWaterPledge_DoesNotStop()
{
// Arrange
var allyChoice = CreateQueuedChoice("water_pledge", 0);
var (script, move, ownChoice) = CreateTestSetup(new BattleChoiceQueue([allyChoice]));
ownChoice.Volatile.Add(new GrassWaterPledgeMove());
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
await Assert.That(allyChoice.Volatile.Contains<GrassWaterPledgeMove>()).IsFalse();
}
/// <summary>
/// Technical test: when the battle has no active choice queue, the move executes normally without
/// throwing.
/// </summary>
[Test]
public async Task StopBeforeMove_NullChoiceQueue_DoesNotStop()
{
// Arrange
var (script, move, _) = CreateTestSetup(null);
var stop = false;
// Act
script.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
}
}

View File

@@ -0,0 +1,66 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using GrassyTerrainScript = PkmnLib.Plugin.Gen7.Scripts.Terrain.GrassyTerrain;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="GrassyTerrain"/> move script.
/// Gen VII Bulbapedia behavior (Generations VI to VII): "Grassy Terrain creates terrain that envelops the
/// field and replaces the background environment and any other terrain that is already in effect."
/// </summary>
public class GrassyTerrainTests
{
private static (GrassyTerrain script, IExecutingMove move, IBattle battle) CreateTestSetup()
{
var script = new GrassyTerrain();
var move = Substitute.For<IExecutingMove>();
var battle = Substitute.For<IBattle>();
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, battle);
}
/// <summary>
/// Bulbapedia: Grassy Terrain "envelops the field and replaces the background environment and any
/// other terrain that is already in effect" — the move sets the battle's terrain to the
/// <see cref="GrassyTerrainScript"/> terrain script.
/// </summary>
[Test]
public void OnSecondaryEffect_SetsGrassyTerrainOnBattle()
{
// Arrange
var (script, move, battle) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
battle.Received(1).SetTerrain(ScriptUtils.ResolveName<GrassyTerrainScript>());
}
/// <summary>
/// Technical test: without battle data on the user (outside of battle) the effect does nothing and
/// does not throw.
/// </summary>
[Test]
public void OnSecondaryEffect_UserHasNoBattleData_DoesNotSetTerrain()
{
// Arrange
var (script, move, battle) = CreateTestSetup();
move.User.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
battle.DidNotReceiveWithAnyArgs().SetTerrain(default);
}
}

View File

@@ -0,0 +1,251 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static.Utils;
using BattleGravity = PkmnLib.Plugin.Gen7.Scripts.Battle.Gravity;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Gravity"/> move script.
/// Gen VII Bulbapedia behavior: "Gravity causes the field to undergo intense gravity lasting 5 turns",
/// and "Semi-invulnerable Pokémon using Fly or Bounce are immediately canceled."
/// </summary>
public class GravityTests
{
/// <summary>
/// Creates a fully mocked test setup for Gravity tests. The battle's volatile script set is a
/// substitute so the addition of the battle-wide gravity script can be inspected.
/// </summary>
private static (Gravity script, IExecutingMove move, IPokemon target, IBattle battle, IScriptSet battleVolatile)
CreateTestSetup(params IReadOnlyList<IPokemon?>[] sidePokemon)
{
var script = new Gravity();
var move = Substitute.For<IExecutingMove>();
move.User.Returns(Substitute.For<IPokemon>());
var battle = Substitute.For<IBattle>();
var battleVolatile = Substitute.For<IScriptSet>();
battle.Volatile.Returns(battleVolatile);
var sides = sidePokemon.Select(pokemon =>
{
var side = Substitute.For<IBattleSide>();
side.Pokemon.Returns(pokemon);
return side;
}).ToArray();
battle.Sides.Returns(sides);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
return (script, move, target, battle, battleVolatile);
}
/// <summary>
/// Creates a mocked Pokémon with a real <see cref="ScriptSet"/> as its volatile set, so effects can
/// be attached and their removal inspected.
/// </summary>
private static (IPokemon pokemon, ScriptSet volatileSet) CreateFieldPokemon()
{
var pokemon = Substitute.For<IPokemon>();
pokemon.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var volatileSet = new ScriptSet(pokemon);
pokemon.Volatile.Returns(volatileSet);
return (pokemon, volatileSet);
}
/// <summary>
/// Bulbapedia: "Gravity causes the field to undergo intense gravity lasting 5 turns." The battle-wide
/// gravity effect is added to the battle's volatile script set.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_AddsGravityToBattleVolatile()
{
// Arrange
var (script, move, target, _, battleVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
battleVolatile.Received(1).StackOrAdd(new StringKey("gravity"), Arg.Any<Func<Script?>>());
await Assert.That(battleVolatile.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "StackOrAdd")).IsTrue();
}
/// <summary>
/// Bulbapedia: the field undergoes "intense gravity" — the instantiation function passed to the
/// battle's volatile script set creates the battle-wide <see cref="BattleGravity"/> script.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GravityInstantiation_CreatesBattleGravityScript()
{
// Arrange
var (script, move, target, battle, battleVolatile) = CreateTestSetup();
battle.Library.Returns(LibraryHelpers.LoadLibrary());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var call = battleVolatile.ReceivedCalls().First(c => c.GetMethodInfo().Name == "StackOrAdd");
var instantiation = (Func<Script?>)call.GetArguments()[1]!;
await Assert.That(instantiation() is BattleGravity).IsTrue();
}
/// <summary>
/// Bulbapedia: "Semi-invulnerable Pokémon using Fly or Bounce are immediately canceled." A Pokémon in
/// the semi-invulnerable turn of Fly has its <see cref="ChargeFlyEffect"/> removed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_PokemonFlying_FlyEffectRemoved()
{
// Arrange
var (pokemon, volatileSet) = CreateFieldPokemon();
volatileSet.Add(new ChargeFlyEffect(pokemon));
var (script, move, target, _, _) = CreateTestSetup(new List<IPokemon?> { pokemon });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<ChargeFlyEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "Semi-invulnerable Pokémon using Fly or Bounce are immediately canceled." A Pokémon in
/// the semi-invulnerable turn of Bounce has its <see cref="ChargeBounceEffect"/> removed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_PokemonBouncing_BounceEffectRemoved()
{
// Arrange
var (pokemon, volatileSet) = CreateFieldPokemon();
volatileSet.Add(new ChargeBounceEffect(pokemon));
var (script, move, target, _, _) = CreateTestSetup(new List<IPokemon?> { pokemon });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<ChargeBounceEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: Gravity cancels Sky Drop — "Semi-invulnerable Pokémon ... are immediately canceled." A
/// Pokémon in the semi-invulnerable turn of Sky Drop has its <see cref="ChargeSkyDropEffect"/>
/// removed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_PokemonInSkyDrop_SkyDropEffectRemoved()
{
// Arrange
var (pokemon, volatileSet) = CreateFieldPokemon();
volatileSet.Add(new ChargeSkyDropEffect(pokemon));
var (script, move, target, _, _) = CreateTestSetup(new List<IPokemon?> { pokemon });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<ChargeSkyDropEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "The effect grounds all Pokémon, removing ... Telekinesis ... effects." A Pokémon under
/// Telekinesis has its <see cref="TelekinesisEffect"/> removed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_PokemonUnderTelekinesis_TelekinesisEffectRemoved()
{
// Arrange
var (pokemon, volatileSet) = CreateFieldPokemon();
volatileSet.Add(new TelekinesisEffect());
var (script, move, target, _, _) = CreateTestSetup(new List<IPokemon?> { pokemon });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<TelekinesisEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "The effect grounds all Pokémon, removing ... Magnet Rise ... effects." A Pokémon under
/// Magnet Rise should have its <see cref="MagnetRiseEffect"/> removed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_PokemonUnderMagnetRise_MagnetRiseEffectRemoved()
{
// Arrange
var (pokemon, volatileSet) = CreateFieldPokemon();
volatileSet.Add(new MagnetRiseEffect());
var (script, move, target, _, _) = CreateTestSetup(new List<IPokemon?> { pokemon });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<MagnetRiseEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "The effect grounds all Pokémon" — Pokémon on every side of the battle are affected,
/// not only those on the target's side.
/// </summary>
[Test]
public async Task OnSecondaryEffect_FlyingPokemonOnBothSides_BothCanceled()
{
// Arrange
var (allyPokemon, allyVolatile) = CreateFieldPokemon();
allyVolatile.Add(new ChargeFlyEffect(allyPokemon));
var (opposingPokemon, opposingVolatile) = CreateFieldPokemon();
opposingVolatile.Add(new ChargeFlyEffect(opposingPokemon));
var (script, move, target, _, _) = CreateTestSetup(new List<IPokemon?> { allyPokemon },
new List<IPokemon?> { opposingPokemon });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(allyVolatile.Contains(ScriptUtils.ResolveName<ChargeFlyEffect>())).IsFalse();
await Assert.That(opposingVolatile.Contains(ScriptUtils.ResolveName<ChargeFlyEffect>())).IsFalse();
}
/// <summary>
/// Technical test: empty (null) slots on a side are skipped without throwing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EmptyPokemonSlot_IsSkipped()
{
// Arrange
var (script, move, target, _, battleVolatile) = CreateTestSetup(new List<IPokemon?> { null });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(battleVolatile.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "StackOrAdd")).IsTrue();
}
/// <summary>
/// Technical test: when the target has no <see cref="IPokemon.BattleData"/> (it is not in a battle),
/// the script does nothing instead of throwing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NullBattleData_DoesNothing()
{
// Arrange
var (script, move, target, _, battleVolatile) = CreateTestSetup();
target.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(battleVolatile.ReceivedCalls().Any()).IsFalse();
}
}

View File

@@ -0,0 +1,118 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Weather;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Growth"/> move script.
/// Gen VII Bulbapedia behavior (Generation V onwards): "Growth now increases the user's Attack by one
/// stage in addition to increasing Special Attack by one stage. In harsh sunlight, Growth raises Attack
/// and Special Attack by two stages each."
/// </summary>
public class GrowthTests
{
private static (Growth script, IExecutingMove move, IPokemon user, IBattle battle) CreateTestSetup(
StringKey? weather = null)
{
var script = new Growth();
var move = Substitute.For<IExecutingMove>();
var battle = Substitute.For<IBattle>();
battle.WeatherName.Returns(weather);
move.Battle.Returns(battle);
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
return (script, move, user, battle);
}
/// <summary>
/// Helper to extract the arguments of the received ChangeStatBoost call for the given statistic, or
/// null when that stat was not boosted. Received-call inspection is used instead of NSubstitute
/// argument matchers because the trailing EventBatchId parameter cannot be bound by <c>Arg.Any</c>.
/// </summary>
private static object?[]? GetStatBoostArgs(IPokemon pokemon, Statistic stat) =>
pokemon.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost").Select(c => c.GetArguments())
.FirstOrDefault(args => (Statistic)args[0]! == stat);
/// <summary>
/// Bulbapedia: "Growth now increases the user's Attack by one stage in addition to increasing Special
/// Attack by one stage." The boosts are self-inflicted.
/// </summary>
[Test, Arguments(Statistic.Attack), Arguments(Statistic.SpecialAttack)]
public async Task OnSecondaryEffect_NoWeather_StatRaisedByOneStage(Statistic stat)
{
// Arrange
var (script, move, user, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
var args = GetStatBoostArgs(user, stat);
await Assert.That(args).IsNotNull();
await Assert.That((sbyte)args![1]!).IsEqualTo((sbyte)1);
await Assert.That((bool)args[2]!).IsTrue(); // self-inflicted
await Assert.That((bool)args[3]!).IsFalse(); // not forced
}
/// <summary>
/// Bulbapedia: "In harsh sunlight, Growth raises Attack and Special Attack by two stages each."
/// </summary>
[Test, Arguments(Statistic.Attack), Arguments(Statistic.SpecialAttack)]
public async Task OnSecondaryEffect_HarshSunlight_StatRaisedByTwoStages(Statistic stat)
{
// Arrange
var (script, move, user, _) = CreateTestSetup(ScriptUtils.ResolveName<HarshSunlight>());
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
var args = GetStatBoostArgs(user, stat);
await Assert.That(args).IsNotNull();
await Assert.That((sbyte)args![1]!).IsEqualTo((sbyte)2);
}
/// <summary>
/// Bulbapedia: the doubled boost only applies "in harsh sunlight" — a different weather still raises
/// the stats by only one stage.
/// </summary>
[Test]
public async Task OnSecondaryEffect_OtherWeather_StatsRaisedByOneStage()
{
// Arrange
var (script, move, user, _) = CreateTestSetup(new StringKey("rain"));
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
var args = GetStatBoostArgs(user, Statistic.Attack);
await Assert.That(args).IsNotNull();
await Assert.That((sbyte)args![1]!).IsEqualTo((sbyte)1);
}
/// <summary>
/// Bulbapedia: only Attack and Special Attack are raised — other stats stay untouched.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_OnlyAttackAndSpecialAttackRaised()
{
// Arrange
var (script, move, user, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(GetStatBoostArgs(user, Statistic.Defense)).IsNull();
await Assert.That(GetStatBoostArgs(user, Statistic.SpecialDefense)).IsNull();
await Assert.That(GetStatBoostArgs(user, Statistic.Speed)).IsNull();
}
}

View File

@@ -0,0 +1,89 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Grudge"/> move script.
/// Gen VII Bulbapedia behavior: "When Grudge is used, if the user faints as the direct result of an
/// attack, the move which causes the user to faint will lose all of its PP."
/// The fainting-and-PP-drain logic itself lives in the <see cref="GrudgeEffect"/> volatile; this move
/// script is responsible for attaching that volatile to the user.
/// </summary>
public class GrudgeTests
{
private static (Grudge script, IExecutingMove move, IPokemon user, IScriptSet userVolatile) CreateTestSetup()
{
var script = new Grudge();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
var userVolatile = Substitute.For<IScriptSet>();
user.Volatile.Returns(userVolatile);
move.User.Returns(user);
return (script, move, user, userVolatile);
}
/// <summary>
/// Bulbapedia: "When Grudge is used" the user starts bearing a grudge — the "grudge" volatile is
/// attached to the user.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_AddsGrudgeVolatileToUser()
{
// Arrange
var (script, move, _, userVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
userVolatile.Received(1).StackOrAdd(new StringKey("grudge"), Arg.Any<Func<Script?>>());
await Assert.That(userVolatile.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "StackOrAdd")).IsTrue();
}
/// <summary>
/// Bulbapedia: "the move which causes the user to faint will lose all of its PP" — the instantiation
/// function passed to the volatile script set creates the <see cref="GrudgeEffect"/> script that
/// implements this.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GrudgeInstantiation_CreatesGrudgeEffectScript()
{
// Arrange
var (script, move, user, userVolatile) = CreateTestSetup();
user.Library.Returns(LibraryHelpers.LoadLibrary());
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
var call = userVolatile.ReceivedCalls().First(c => c.GetMethodInfo().Name == "StackOrAdd");
var instantiation = (Func<Script?>)call.GetArguments()[1]!;
await Assert.That(instantiation() is GrudgeEffect).IsTrue();
}
/// <summary>
/// Bulbapedia: the grudge is borne by the user, not the target — the target's volatile script set is
/// left untouched.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_DoesNotAddVolatileToTarget()
{
// Arrange
var (script, move, _, _) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
var targetVolatile = Substitute.For<IScriptSet>();
target.Volatile.Returns(targetVolatile);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.ReceivedCalls().Any()).IsFalse();
}
}

View File

@@ -0,0 +1,123 @@
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="GuardSplit"/> move script.
/// Gen VII Bulbapedia behavior: "Guard Split averages the user's Defense and Special Defense stats with
/// those of the target Pokémon." and "Both the user's and the target's stat changes are ignored when
/// calculating the average." (the calculation therefore uses the flat, unboosted stats).
/// </summary>
public class GuardSplitTests
{
private static (GuardSplit script, IExecutingMove move, IPokemon user, IPokemon target) CreateTestSetup(
uint userDefense, uint userSpecialDefense, uint targetDefense, uint targetSpecialDefense)
{
var script = new GuardSplit();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.FlatStats.Returns(new StatisticSet<uint>(100, 100, userDefense, 100, userSpecialDefense, 100));
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
target.FlatStats.Returns(new StatisticSet<uint>(100, 100, targetDefense, 100, targetSpecialDefense, 100));
return (script, move, user, target);
}
/// <summary>
/// Bulbapedia: "Guard Split averages the user's Defense and Special Defense stats with those of the
/// target Pokémon." — both Pokémon end up with the averaged Defense value.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DifferentDefenseStats_BothGetAveragedDefense()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(40, 100, 60, 100);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(user.FlatStats.GetStatistic(Statistic.Defense)).IsEqualTo(50u);
await Assert.That(target.FlatStats.GetStatistic(Statistic.Defense)).IsEqualTo(50u);
}
/// <summary>
/// Bulbapedia: the averaging also covers "Special Defense" — both Pokémon end up with the averaged
/// Special Defense value.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DifferentSpecialDefenseStats_BothGetAveragedSpecialDefense()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(100, 80, 100, 120);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(user.FlatStats.GetStatistic(Statistic.SpecialDefense)).IsEqualTo(100u);
await Assert.That(target.FlatStats.GetStatistic(Statistic.SpecialDefense)).IsEqualTo(100u);
}
/// <summary>
/// The average of an odd sum is truncated by integer division, matching the games' integer stat
/// arithmetic.
/// </summary>
[Test, Arguments(35u, 40u, 37u), Arguments(1u, 2u, 1u), Arguments(99u, 100u, 99u)]
public async Task OnSecondaryEffect_OddDefenseSum_AverageIsTruncated(uint userDefense, uint targetDefense,
uint expectedAverage)
{
// Arrange
var (script, move, user, target) = CreateTestSetup(userDefense, 100, targetDefense, 100);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(user.FlatStats.GetStatistic(Statistic.Defense)).IsEqualTo(expectedAverage);
await Assert.That(target.FlatStats.GetStatistic(Statistic.Defense)).IsEqualTo(expectedAverage);
}
/// <summary>
/// Bulbapedia: only Defense and Special Defense are averaged — the other stats are left untouched.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_OtherStatsUnchanged()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(40, 80, 60, 120);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(user.FlatStats.GetStatistic(Statistic.Attack)).IsEqualTo(100u);
await Assert.That(user.FlatStats.GetStatistic(Statistic.SpecialAttack)).IsEqualTo(100u);
await Assert.That(user.FlatStats.GetStatistic(Statistic.Speed)).IsEqualTo(100u);
await Assert.That(user.FlatStats.GetStatistic(Statistic.Hp)).IsEqualTo(100u);
}
/// <summary>
/// The changed flat stats must be propagated to the effective in-battle stats — both Pokémon get
/// their boosted stats recalculated.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_RecalculatesBoostedStatsOnBothPokemon()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(40, 80, 60, 120);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
user.Received(1).RecalculateBoostedStats();
target.Received(1).RecalculateBoostedStats();
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "RecalculateBoostedStats")).IsTrue();
}
}

View File

@@ -0,0 +1,145 @@
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="GuardSwap"/> move script.
/// Gen VII Bulbapedia behavior: "Guard Swap switches the user's Defense stat stages and Special Defense
/// stat stages with the target's Defense stat stages and Special Defense stat stages."
/// </summary>
public class GuardSwapTests
{
private static (GuardSwap script, IExecutingMove move, IPokemon user, IPokemon target) CreateTestSetup(
sbyte userDefense, sbyte userSpecialDefense, sbyte targetDefense, sbyte targetSpecialDefense)
{
var script = new GuardSwap();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.StatBoost.Returns(new StatBoostStatisticSet(0, 0, userDefense, 0, userSpecialDefense, 0));
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
target.StatBoost.Returns(new StatBoostStatisticSet(0, 0, targetDefense, 0, targetSpecialDefense, 0));
return (script, move, user, target);
}
/// <summary>
/// Helper to extract the arguments of the received ChangeStatBoost call for the given statistic, or
/// null when that stat was not changed. Received-call inspection is used instead of NSubstitute
/// argument matchers because the trailing EventBatchId parameter cannot be bound by <c>Arg.Any</c>.
/// </summary>
private static object?[]? GetStatBoostArgs(IPokemon pokemon, Statistic stat) =>
pokemon.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost").Select(c => c.GetArguments())
.FirstOrDefault(args => (Statistic)args[0]! == stat);
/// <summary>
/// Bulbapedia: "Guard Swap switches the user's Defense stat stages ... with the target's Defense stat
/// stages." The user at +2 Defense and the target at -1 Defense must end up at -1 and +2
/// respectively, so the user is changed by -3 and the target by +3.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DifferentDefenseStages_StagesAreSwapped()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(2, 0, -1, 0);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var userArgs = GetStatBoostArgs(user, Statistic.Defense);
var targetArgs = GetStatBoostArgs(target, Statistic.Defense);
await Assert.That(userArgs).IsNotNull();
await Assert.That((sbyte)userArgs![1]!).IsEqualTo((sbyte)-3);
await Assert.That(targetArgs).IsNotNull();
await Assert.That((sbyte)targetArgs![1]!).IsEqualTo((sbyte)3);
}
/// <summary>
/// Bulbapedia: the swap also covers the "Special Defense stat stages". The user at -2 Special Defense
/// and the target at +3 must end up at +3 and -2 respectively, so the user is changed by +5 and the
/// target by -5.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DifferentSpecialDefenseStages_StagesAreSwapped()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(0, -2, 0, 3);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var userArgs = GetStatBoostArgs(user, Statistic.SpecialDefense);
var targetArgs = GetStatBoostArgs(target, Statistic.SpecialDefense);
await Assert.That(userArgs).IsNotNull();
await Assert.That((sbyte)userArgs![1]!).IsEqualTo((sbyte)5);
await Assert.That(targetArgs).IsNotNull();
await Assert.That((sbyte)targetArgs![1]!).IsEqualTo((sbyte)-5);
}
/// <summary>
/// Bulbapedia: the swap is unconditional — the change is applied with force so clamping or
/// prevention effects cannot block the exchange of stages.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_ChangesAreForced()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(2, 0, -1, 0);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var userArgs = GetStatBoostArgs(user, Statistic.Defense);
var targetArgs = GetStatBoostArgs(target, Statistic.Defense);
await Assert.That((bool)userArgs![3]!).IsTrue(); // forced
await Assert.That((bool)targetArgs![3]!).IsTrue(); // forced
await Assert.That((bool)userArgs[2]!).IsTrue(); // self-inflicted for the user
await Assert.That((bool)targetArgs[2]!).IsFalse(); // not self-inflicted for the target
}
/// <summary>
/// Bulbapedia: only the "Defense stat stages and Special Defense stat stages" are switched — the
/// other stat stages are left untouched.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_OtherStagesUnchanged()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(2, -2, -1, 3);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetStatBoostArgs(user, Statistic.Attack)).IsNull();
await Assert.That(GetStatBoostArgs(user, Statistic.SpecialAttack)).IsNull();
await Assert.That(GetStatBoostArgs(user, Statistic.Speed)).IsNull();
await Assert.That(GetStatBoostArgs(target, Statistic.Attack)).IsNull();
}
/// <summary>
/// When both Pokémon have equal stages, swapping is a net-zero change for both sides.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EqualStages_ChangeIsZero()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(1, 2, 1, 2);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var userDefenseArgs = GetStatBoostArgs(user, Statistic.Defense);
var userSpecialDefenseArgs = GetStatBoostArgs(user, Statistic.SpecialDefense);
await Assert.That((sbyte)userDefenseArgs![1]!).IsEqualTo((sbyte)0);
await Assert.That((sbyte)userSpecialDefenseArgs![1]!).IsEqualTo((sbyte)0);
}
}

View File

@@ -0,0 +1,81 @@
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="GuardianOfAlola"/> move script.
/// Gen VII Bulbapedia behavior: "Guardian of Alola deals damage equal to 75% of the target's remaining HP
/// (rounded down, but no less than 1)."
/// </summary>
public class GuardianOfAlolaTests
{
private static (GuardianOfAlola script, IExecutingMove move, IPokemon target) CreateTestSetup(uint maxHp,
uint currentHp)
{
var script = new GuardianOfAlola();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.BoostedStats.Returns(new StatisticSet<uint>(maxHp, 100, 100, 100, 100, 100));
target.CurrentHealth.Returns(currentHp);
return (script, move, target);
}
/// <summary>
/// Bulbapedia: "Guardian of Alola deals damage equal to 75% of the target's remaining HP". A target
/// at full HP takes 75% of that HP as damage.
/// </summary>
[Test]
public async Task ChangeMoveDamage_TargetAtFullHp_DamageIsThreeQuartersOfHp()
{
// Arrange
var (script, move, target) = CreateTestSetup(400, 400);
uint damage = 10;
// Act
script.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(300u);
}
/// <summary>
/// Bulbapedia: the damage is "equal to 75% of the target's remaining HP", not its maximum HP. A
/// target with 400 max HP but only 100 remaining takes 75 damage.
/// </summary>
[Test, Arguments(400u, 100u, 75u), Arguments(400u, 200u, 150u), Arguments(300u, 50u, 37u)]
public async Task ChangeMoveDamage_TargetDamaged_DamageIsThreeQuartersOfRemainingHp(uint maxHp, uint currentHp,
uint expectedDamage)
{
// Arrange
var (script, move, target) = CreateTestSetup(maxHp, currentHp);
uint damage = 10;
// Act
script.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: the damage is "rounded down, but no less than 1" — a target with a single HP remaining
/// still takes 1 damage instead of 0.
/// </summary>
[Test]
public async Task ChangeMoveDamage_TargetAtOneHp_DamageIsAtLeastOne()
{
// Arrange
var (script, move, target) = CreateTestSetup(400, 1);
uint damage = 10;
// Act
script.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(1u);
}
}

View File

@@ -0,0 +1,108 @@
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="GyroBall"/> move script.
/// Gen VII Bulbapedia behavior: "Gyro Ball inflicts more damage the slower the user is compared to the
/// target", with base power = min(150, 25 × target Speed / user Speed + 1).
/// </summary>
public class GyroBallTests
{
private static (GyroBall script, IExecutingMove move, IPokemon target) CreateTestSetup(uint userSpeed,
uint targetSpeed)
{
var script = new GyroBall();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.BoostedStats.Returns(new StatisticSet<uint>(100, 100, 100, 100, 100, userSpeed));
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
target.BoostedStats.Returns(new StatisticSet<uint>(100, 100, 100, 100, 100, targetSpeed));
return (script, move, target);
}
/// <summary>
/// Bulbapedia: "BasePower = min(150, 25 × CurrentSpeed(target) / CurrentSpeed(user) + 1)". Various
/// speed combinations, including the integer truncation of the division.
/// </summary>
[Test, Arguments(100u, 100u, (ushort)26), Arguments(100u, 50u, (ushort)13), Arguments(100u, 1u, (ushort)1),
Arguments(50u, 200u, (ushort)101)]
// 25 * 100 / 100 + 1
// 25 * 50 / 100 = 12 (truncated) + 1
// 25 * 1 / 100 = 0 (truncated) + 1
// 25 * 200 / 50 + 1
public async Task ChangeBasePower_SpeedComparison_FollowsFormula(uint userSpeed, uint targetSpeed,
ushort expectedPower)
{
// Arrange
var (script, move, target) = CreateTestSetup(userSpeed, targetSpeed);
ushort basePower = 5;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expectedPower);
}
/// <summary>
/// Bulbapedia: "The move reaches its maximum base power of 150" — a formula result just above the cap
/// is clamped to 150.
/// </summary>
[Test]
public async Task ChangeBasePower_FormulaExceedsCap_BasePowerCappedAt150()
{
// Arrange - 25 * 60 / 10 + 1 = 151
var (script, move, target) = CreateTestSetup(10, 60);
ushort basePower = 5;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)150);
}
/// <summary>
/// Bulbapedia: the base power is capped at 150 for any speed ratio. The implementation truncates the
/// formula result to a byte before applying the cap, so results above 255 wrap around and produce a
/// wrong power (25 × 100 / 4 + 1 = 626 wraps to 114).
/// </summary>
[Test]
public async Task ChangeBasePower_FormulaFarExceedsByteRange_BasePowerCappedAt150()
{
// Arrange - 25 * 100 / 4 + 1 = 626
var (script, move, target) = CreateTestSetup(4, 100);
ushort basePower = 5;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)150);
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "If the user of Gyro Ball has a Speed stat that rounds down to
/// 0, the move's power is set to 1."
/// </summary>
[Test]
public async Task ChangeBasePower_UserSpeedIsZero_BasePowerIsOne()
{
// Arrange
var (script, move, target) = CreateTestSetup(0, 100);
ushort basePower = 5;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)1);
}
}

View File

@@ -0,0 +1,193 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
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="HealBell"/> move script.
/// Gen VII Bulbapedia behavior: "Heal Bell cures the user, all Pokémon in the user's party, and the user's
/// allies, of all status conditions (sleep, poison, paralysis, freeze, and burn)." Since Generation VI:
/// "Heal Bell no longer affects active Pokémon with the Ability Soundproof. Inactive Pokémon are healed
/// regardless of Abilities."
/// </summary>
public class HealBellTests
{
/// <summary>
/// Creates a mocked party member with a mocked <see cref="IPokemon.Volatile"/> set and
/// <see cref="IPokemon.BattleData"/>, optionally active on the battlefield and with a named ability.
/// </summary>
private static IPokemon CreatePartyMember(bool onBattlefield = false, string? abilityName = null)
{
var pokemon = Substitute.For<IPokemon>();
var volatileSet = Substitute.For<IScriptSet>();
pokemon.Volatile.Returns(volatileSet);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.IsOnBattlefield.Returns(onBattlefield);
pokemon.BattleData.Returns(battleData);
if (abilityName != null)
{
var ability = Substitute.For<IAbility>();
ability.Name.Returns(new StringKey(abilityName));
pokemon.ActiveAbility.Returns(ability);
}
return pokemon;
}
/// <summary>
/// Creates a fully mocked test setup: the user plus the given other party members form a single party
/// in the battle. Returns the script, the executing move and the user (which is the move's target in
/// most tests, as Heal Bell targets the user's side).
/// </summary>
private static (HealBell script, IExecutingMove move, IPokemon user) CreateTestSetup(
params IPokemon?[] otherPartyMembers)
{
var script = new HealBell();
var user = CreatePartyMember(true);
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 battle = Substitute.For<IBattle>();
battle.Parties.Returns(new[] { battleParty });
user.BattleData!.Battle.Returns(battle);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
return (script, move, user);
}
/// <summary>
/// Helper that checks whether a mocked Pokémon received a <see cref="IPokemon.ClearStatus"/> call.
/// </summary>
private static bool ReceivedClearStatus(IPokemon pokemon) =>
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ClearStatus");
/// <summary>
/// Bulbapedia: "Heal Bell cures the user, all Pokémon in the user's party, and the user's allies, of all
/// status conditions (sleep, poison, paralysis, freeze, and burn)." Every non-empty slot of the user's
/// party is cured, including inactive Pokémon; empty party slots are skipped without throwing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetInUsersParty_CuresStatusOfAllPartyMembers()
{
// Arrange
var inactiveMember = CreatePartyMember();
var activeMember = CreatePartyMember(true);
var (script, move, user) = CreateTestSetup(inactiveMember, activeMember, null);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(ReceivedClearStatus(user)).IsTrue();
await Assert.That(ReceivedClearStatus(inactiveMember)).IsTrue();
await Assert.That(ReceivedClearStatus(activeMember)).IsTrue();
}
/// <summary>
/// Bulbapedia: Heal Bell cures "all Pokémon in the user's party". A target that is not in any party
/// (e.g. outside of the battle's known parties) results in no effect and no exception.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetNotInAnyParty_DoesNothing()
{
// Arrange
var (script, move, user) = CreateTestSetup();
var strayTarget = CreatePartyMember();
// Act
script.OnSecondaryEffect(move, strayTarget, 0);
// Assert - nobody is cured because no party contains the target
await Assert.That(ReceivedClearStatus(user)).IsFalse();
await Assert.That(ReceivedClearStatus(strayTarget)).IsFalse();
}
/// <summary>
/// Technical test: outside of battle the user has no <see cref="IPokemon.BattleData"/>; the script
/// does nothing and does not throw.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserHasNoBattleData_DoesNothing()
{
// Arrange
var (script, move, user) = CreateTestSetup();
user.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(ReceivedClearStatus(user)).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation VI, still applicable in Gen VII): "Inactive Pokémon are healed regardless of
/// Abilities." A party member with Soundproof that is not on the battlefield is still cured.
/// </summary>
[Test]
public async Task OnSecondaryEffect_InactiveSoundproofPartyMember_StillCured()
{
// Arrange
var soundproofMember = CreatePartyMember(false, "soundproof");
var (script, move, user) = CreateTestSetup(soundproofMember);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(ReceivedClearStatus(soundproofMember)).IsTrue();
}
/// <summary>
/// Bulbapedia (Generation VI, still applicable in Gen VII): "Heal Bell no longer affects active Pokémon
/// with the Ability Soundproof." An active party member with Soundproof is not cured.
/// </summary>
[Test]
public async Task OnSecondaryEffect_ActiveSoundproofPartyMember_NotCured()
{
// Arrange
var soundproofMember = CreatePartyMember(true, "soundproof");
var (script, move, user) = CreateTestSetup(soundproofMember);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(ReceivedClearStatus(soundproofMember)).IsFalse();
}
/// <summary>
/// Bulbapedia: Heal Bell cures "all status conditions (sleep, poison, paralysis, freeze, and burn)".
/// Confusion is a volatile status, not a status condition, and is not cured by Heal Bell; a confused
/// party member keeps its <see cref="Confusion"/> volatile script.
/// </summary>
[Test]
public async Task OnSecondaryEffect_ConfusedPartyMember_ConfusionNotCured()
{
// Arrange
var confusedMember = CreatePartyMember();
var confusionKey = ScriptUtils.ResolveName<Confusion>();
confusedMember.Volatile.Contains(confusionKey).Returns(true);
var (script, move, user) = CreateTestSetup(confusedMember);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert - the confusion volatile is not removed
await Assert.That(confusedMember.Volatile.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove"))
.IsFalse();
}
}

View File

@@ -0,0 +1,294 @@
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.Common;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static.Moves;
using PkmnLib.Static.Species;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="HealBlock"/> move script and its <see cref="HealBlockEffect"/> volatile.
/// Gen VII Bulbapedia behavior: "Heal Block restricts the targets from healing in certain ways for five
/// turns." Affected Pokémon cannot use HP-recovery moves, and since Generation VI "Affected Pokémon can no
/// longer use HP-draining moves." Since Generation V they can "no longer be healed by Black Sludge,
/// Leftovers, or Shell Bell", but "Heal Block will not prevent Pokémon with the Ability Regenerator from
/// having their HP restored upon switching out."
/// </summary>
public class HealBlockTests
{
/// <summary>
/// Creates a mocked Pokémon whose <see cref="IPokemon.Volatile"/> is a real <see cref="ScriptSet"/>,
/// so scripts can be added and can remove themselves.
/// </summary>
private static (IPokemon pokemon, ScriptSet volatileSet) CreatePokemonWithRealVolatile()
{
var pokemon = Substitute.For<IPokemon>();
var volatileSet = new ScriptSet(pokemon);
pokemon.Volatile.Returns(volatileSet);
pokemon.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
return (pokemon, volatileSet);
}
/// <summary>
/// Creates a mocked move choice whose chosen move does or does not carry the
/// <see cref="MoveFlags.Heal"/> flag.
/// </summary>
private static IMoveChoice CreateMoveChoice(bool hasHealFlag)
{
var moveData = Substitute.For<IMoveData>();
moveData.HasFlag(MoveFlags.Heal).Returns(hasHealFlag);
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
var choice = Substitute.For<IMoveChoice>();
choice.ChosenMove.Returns(learnedMove);
return choice;
}
/// <summary>
/// Creates a mocked executing move whose chosen move does or does not carry the
/// <see cref="MoveFlags.Heal"/> flag.
/// </summary>
private static IExecutingMove CreateExecutingMove(bool hasHealFlag)
{
var moveData = Substitute.For<IMoveData>();
moveData.HasFlag(MoveFlags.Heal).Returns(hasHealFlag);
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
var move = Substitute.For<IExecutingMove>();
move.ChosenMove.Returns(learnedMove);
return move;
}
/// <summary>
/// Bulbapedia: "Heal Block restricts the targets from healing in certain ways for five turns."
/// Using the move attaches the <see cref="HealBlockEffect"/> volatile to the target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Target_AddsHealBlockEffectToTarget()
{
// Arrange
var script = new HealBlock();
var move = Substitute.For<IExecutingMove>();
var (target, targetVolatile) = CreatePokemonWithRealVolatile();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<HealBlockEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: Heal Block prevents the target from using HP-recovery moves. A move carrying the
/// <see cref="MoveFlags.Heal"/> flag cannot be selected while the effect is active.
/// </summary>
[Test]
public async Task PreventMoveSelection_HealingMove_PreventsSelection()
{
// Arrange
var effect = new HealBlockEffect();
var choice = CreateMoveChoice(true);
var prevent = false;
// Act
effect.PreventMoveSelection(choice, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: only healing moves are restricted; other moves remain selectable while under the
/// effect of Heal Block.
/// </summary>
[Test]
public async Task PreventMoveSelection_NonHealingMove_DoesNotPreventSelection()
{
// Arrange
var effect = new HealBlockEffect();
var choice = CreateMoveChoice(false);
var prevent = false;
// Act
effect.PreventMoveSelection(choice, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia (Generations VI to VII): "Affected Pokémon can no longer use HP-draining moves."
/// Integration check: Absorb's actual Gen7 move data carries the heal flag, so selecting it is
/// prevented while under the effect of Heal Block.
/// </summary>
[Test]
public async Task PreventMoveSelection_DrainMoveFromGen7Data_PreventsSelection()
{
// Arrange
var library = LibraryHelpers.LoadLibrary();
await Assert.That(library.StaticLibrary.Moves.TryGet("absorb", out var absorb)).IsTrue();
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(absorb!);
var choice = Substitute.For<IMoveChoice>();
choice.ChosenMove.Returns(learnedMove);
var effect = new HealBlockEffect();
var prevent = false;
// Act
effect.PreventMoveSelection(choice, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: Heal Block prevents the target from using HP-recovery moves. If a healing move would
/// still execute (e.g. it was chosen before Heal Block hit), its execution is prevented.
/// </summary>
[Test]
public async Task PreventMove_HealingMove_PreventsExecution()
{
// Arrange
var effect = new HealBlockEffect();
var move = CreateExecutingMove(true);
var prevent = false;
// Act
effect.PreventMove(move, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: only healing moves are restricted; execution of non-healing moves is not prevented.
/// </summary>
[Test]
public async Task PreventMove_NonHealingMove_DoesNotPreventExecution()
{
// Arrange
var effect = new HealBlockEffect();
var move = CreateExecutingMove(false);
var prevent = false;
// Act
effect.PreventMove(move, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation V onwards): "Affected Pokémon can no longer be healed by Black Sludge,
/// Leftovers, or Shell Bell", nor by other healing effects. Any incoming heal on the affected
/// Pokémon is prevented.
/// </summary>
[Test]
public async Task PreventHeal_AnyHealing_PreventsHeal()
{
// Arrange
var effect = new HealBlockEffect();
var pokemon = Substitute.For<IPokemon>();
var prevented = false;
// Act
effect.PreventHeal(pokemon, 10, false, ref prevented);
// Assert
await Assert.That(prevented).IsTrue();
}
/// <summary>
/// Bulbapedia (Generation V onwards): "Heal Block will not prevent Pokémon with the Ability Regenerator
/// from having their HP restored upon switching out." Run through the real switch-out flow (the direct
/// <see cref="IBattleSide.SwapPokemon(byte, IPokemon?)"/> call moves such as U-turn make), a damaged
/// Pokémon with Regenerator under Heal Block still restores 1/3 of its max HP.
/// </summary>
[Test]
public async Task SwitchOut_RegeneratorUnderHealBlock_RestoresThirdOfMaxHealth()
{
// Arrange
var library = LibraryHelpers.LoadLibrary();
await Assert.That(library.StaticLibrary.Species.TryGet("mienfoo", out var species)).IsTrue();
IPokemon CreateMienfoo(byte abilityIndex) => new PokemonImpl(library, species!, species!.GetDefaultForm(),
new AbilityIndex { IsHidden = false, Index = abilityIndex }, 50, 0, Gender.Male, 0, "hardy");
var regeneratorMon = CreateMienfoo(1);
await Assert.That(regeneratorMon.ActiveAbility!.Name).IsEqualTo(new StringKey("regenerator"));
var benchMon = CreateMienfoo(0);
var opponent = CreateMienfoo(0);
var party = new PokemonPartyImpl(6);
party.SwapInto(regeneratorMon, 0);
party.SwapInto(benchMon, 1);
var opponentParty = new PokemonPartyImpl(6);
opponentParty.SwapInto(opponent, 0);
using var battle = new BattleImpl(library, [
new BattlePartyImpl(party, [new ResponsibleIndex(0, 0)]),
new BattlePartyImpl(opponentParty, [new ResponsibleIndex(1, 0)]),
], false, 2, 1, false, "grass", 10);
battle.Sides[0].SwapPokemon(0, regeneratorMon);
battle.Sides[1].SwapPokemon(0, opponent);
regeneratorMon.Damage(60, DamageSource.MoveDamage, new EventBatchId());
regeneratorMon.Volatile.Add(new HealBlockEffect());
// Sanity check: the effect is live and blocks an ordinary heal.
await Assert.That(regeneratorMon.Heal(10)).IsFalse();
var healthBeforeSwitch = regeneratorMon.CurrentHealth;
// Act
battle.Sides[0].SwapPokemon(0, benchMon);
// Assert
await Assert.That(regeneratorMon.CurrentHealth).IsEqualTo(healthBeforeSwitch + regeneratorMon.MaxHealth / 3);
}
/// <summary>
/// Bulbapedia: "Heal Block restricts the targets from healing in certain ways for five turns."
/// After four end-of-turn ticks the effect is still active.
/// </summary>
[Test]
public async Task OnEndTurn_FourTurnsPassed_EffectStillActive()
{
// Arrange
var (pokemon, volatileSet) = CreatePokemonWithRealVolatile();
var effect = new HealBlockEffect();
volatileSet.Add(effect);
var battle = Substitute.For<IBattle>();
// Act
for (var i = 0; i < 4; i++)
effect.OnEndTurn(pokemon, battle);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<HealBlockEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "Heal Block restricts the targets from healing in certain ways for five turns."
/// After the fifth end-of-turn tick the effect removes itself.
/// </summary>
[Test]
public async Task OnEndTurn_FiveTurnsPassed_EffectRemovesItself()
{
// Arrange
var (pokemon, volatileSet) = CreatePokemonWithRealVolatile();
var effect = new HealBlockEffect();
volatileSet.Add(effect);
var battle = Substitute.For<IBattle>();
// Act
for (var i = 0; i < 5; i++)
effect.OnEndTurn(pokemon, battle);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<HealBlockEffect>())).IsFalse();
}
}

View File

@@ -0,0 +1,275 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Side;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="HealingWish"/> move script and its side effect script
/// <see cref="HealingWishEffect"/>.
/// Gen VII Bulbapedia behavior: "Healing Wish causes the user to faint, then switches in the new Pokémon.
/// The new Pokémon's HP is restored and it is cured of any status conditions upon being sent out.", where
/// from Generation V to VII "The receiving Pokémon is now sent out at the end of the turn rather than
/// immediately."
/// </summary>
public class HealingWishTests
{
/// <summary>
/// Creates a fully mocked test setup for the <see cref="HealingWish"/> move script, with the user at
/// side 0 and the given position, and the side's volatile scripts backed by a real
/// <see cref="ScriptSet"/>.
/// </summary>
private static (HealingWish script, IExecutingMove move, IPokemon user, IScriptSet sideVolatile) CreateMoveSetup(
byte position = 1, bool hasUsablePartyMembers = true)
{
var script = new HealingWish();
var move = Substitute.For<IExecutingMove>();
var side = Substitute.For<IBattleSide>();
// ScriptSet.Add runs the IScriptPreventVolatileAdd hook over the owner's scripts; give the mock a
// real (empty) iterator so the hook pass runs.
side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet sideVolatile = new ScriptSet(side);
side.VolatileScripts.Returns(sideVolatile);
var party = Substitute.For<IBattleParty>();
party.IsResponsibleForIndex(Arg.Any<ResponsibleIndex>()).Returns(true);
party.HasUsablePokemonNotInField().Returns(hasUsablePartyMembers);
var battle = Substitute.For<IBattle>();
battle.Sides.Returns(new[] { side });
battle.Parties.Returns(new[] { party });
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
battleData.SideIndex.Returns((byte)0);
battleData.Position.Returns(position);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
move.User.Returns(user);
return (script, move, user, sideVolatile);
}
/// <summary>
/// Creates a mocked replacement Pokémon with the given maximum HP for
/// <see cref="HealingWishEffect"/> tests.
/// </summary>
private static IPokemon CreateReplacement(uint maxHp = 200)
{
var pokemon = Substitute.For<IPokemon>();
pokemon.BoostedStats.Returns(new StatisticSet<uint>(maxHp, 0, 0, 0, 0, 0));
return pokemon;
}
/// <summary>
/// Helper to extract the heal amount from a substitute's received Heal calls, or null when Heal was
/// never called.
/// </summary>
private static uint? GetHealAmount(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Heal");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Bulbapedia: "Healing Wish causes the user to faint".
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserInBattle_UserFaints()
{
// Arrange
var (script, move, user, _) = CreateMoveSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Faint")).IsTrue();
}
/// <summary>
/// Bulbapedia (Generations V to VII): "The receiving Pokémon is now sent out at the end of the turn
/// rather than immediately." — using the move leaves a pending <see cref="HealingWishEffect"/> on the
/// user's side instead of healing anything directly.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserInBattle_AddsHealingWishEffectToUserSide()
{
// Arrange
var (script, move, _, sideVolatile) = CreateMoveSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName<HealingWishEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: the wish benefits the Pokémon "sent out" in the user's place — the pending effect is
/// bound to the position the user fainted at, so a replacement entering that position is healed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserAtPosition_EffectTargetsUsersPosition()
{
// Arrange
var (script, move, _, sideVolatile) = CreateMoveSetup(1);
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
var effect = (HealingWishEffect)sideVolatile.Get(new StringKey("healing_wish"))!.Script!;
var replacement = CreateReplacement();
// Act
effect.OnSwitchIn(replacement, 1);
// Assert
await Assert.That(GetHealAmount(replacement)).IsNotNull();
}
/// <summary>
/// Technical test: outside of battle (no <see cref="IPokemon.BattleData"/>) the hook does nothing and
/// the user does not faint.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NoBattleData_UserDoesNotFaint()
{
// Arrange
var script = new HealingWish();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
move.User.Returns(user);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Faint")).IsFalse();
}
/// <summary>
/// Bulbapedia: "Healing Wish will fail if its Trainer has no other conscious Pokémon in their party".
/// </summary>
[Test]
public async Task OnSecondaryEffect_NoOtherConsciousPartyMembers_MoveFails()
{
// Arrange
var (script, move, _, _) = CreateMoveSetup(hasUsablePartyMembers: false);
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue();
}
/// <summary>
/// Bulbapedia: when the move fails for lack of other conscious party members, the user does not faint
/// and no pending effect is left on the side.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NoOtherConsciousPartyMembers_UserDoesNotFaint()
{
// Arrange
var (script, move, user, sideVolatile) = CreateMoveSetup(hasUsablePartyMembers: false);
var target = Substitute.For<IPokemon>();
move.GetHitData(target, 0).Returns(Substitute.For<IHitData>());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Faint")).IsFalse();
await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName<HealingWishEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "The new Pokémon's HP is restored ... upon being sent out." — the replacement switching
/// into the wisher's position is healed by its full maximum HP.
/// </summary>
[Test, Arguments(200u), Arguments(1u), Arguments(999u)]
public async Task OnSwitchIn_MatchingPosition_HealsReplacementToFullHp(uint maxHp)
{
// Arrange
var effect = new HealingWishEffect(1);
var replacement = CreateReplacement(maxHp);
// Act
effect.OnSwitchIn(replacement, 1);
// Assert
await Assert.That(GetHealAmount(replacement)!.Value).IsEqualTo(maxHp);
}
/// <summary>
/// Bulbapedia: "it is cured of any status conditions upon being sent out."
/// </summary>
[Test]
public async Task OnSwitchIn_MatchingPosition_CuresStatus()
{
// Arrange
var effect = new HealingWishEffect(1);
var replacement = CreateReplacement();
// Act
effect.OnSwitchIn(replacement, 1);
// Assert
await Assert.That(replacement.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ClearStatus")).IsTrue();
}
/// <summary>
/// Bulbapedia: the wish applies to the Pokémon sent out in the user's place — a Pokémon switching in
/// at a different position on the side is not healed and does not consume the effect.
/// </summary>
[Test]
public async Task OnSwitchIn_DifferentPosition_DoesNotHealAndEffectPersists()
{
// Arrange
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet sideVolatile = new ScriptSet(side);
var effect = new HealingWishEffect(1);
sideVolatile.Add(effect);
var otherPokemon = CreateReplacement();
// Act
effect.OnSwitchIn(otherPokemon, 0);
// Assert
await Assert.That(GetHealAmount(otherPokemon)).IsNull();
await Assert.That(otherPokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ClearStatus")).IsFalse();
await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName<HealingWishEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia (Generations V to VII): the wish is consumed by the Pokémon sent out at the position —
/// unlike Generation VIII onwards there is no check on the replacement's HP or status, so the effect
/// is removed from the side after the first switch-in even if the replacement was already at full
/// health.
/// </summary>
[Test]
public async Task OnSwitchIn_MatchingPosition_EffectIsRemovedFromSide()
{
// Arrange
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet sideVolatile = new ScriptSet(side);
var effect = new HealingWishEffect(1);
sideVolatile.Add(effect);
// Act
effect.OnSwitchIn(CreateReplacement(), 1);
// Assert
await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName<HealingWishEffect>())).IsFalse();
}
}

View File

@@ -0,0 +1,165 @@
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="HeartSwap"/> move script.
/// Gen VII Bulbapedia behavior: "Heart Swap switches the user's stat stages with the target's stat
/// stages."
/// </summary>
public class HeartSwapTests
{
private static (HeartSwap script, IExecutingMove move, IPokemon user, IPokemon target) CreateTestSetup(
StatBoostStatisticSet userBoosts, StatBoostStatisticSet targetBoosts)
{
var script = new HeartSwap();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.StatBoost.Returns(userBoosts);
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
target.StatBoost.Returns(targetBoosts);
return (script, move, user, target);
}
/// <summary>
/// Helper to extract the arguments of the received ChangeStatBoost call for the given statistic, or
/// null when that stat was not changed. Received-call inspection is used instead of NSubstitute
/// argument matchers because the trailing EventBatchId parameter cannot be bound by <c>Arg.Any</c>.
/// </summary>
private static object?[]? GetStatBoostArgs(IPokemon pokemon, Statistic stat) =>
pokemon.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost").Select(c => c.GetArguments())
.FirstOrDefault(args => (Statistic)args[0]! == stat);
/// <summary>
/// Bulbapedia: "Heart Swap switches the user's stat stages with the target's stat stages." The user
/// must end up at the target's stage and vice versa, so the user is changed by
/// (target - user) and the target by (user - target).
/// </summary>
[Test, Arguments((sbyte)2, (sbyte)-3, (sbyte)-5, (sbyte)5), Arguments((sbyte)-1, (sbyte)4, (sbyte)5, (sbyte)-5),
Arguments((sbyte)6, (sbyte)-6, (sbyte)-12, (sbyte)12), Arguments((sbyte)0, (sbyte)1, (sbyte)1, (sbyte)-1)]
public async Task OnSecondaryEffect_DifferentAttackStages_StagesAreSwapped(sbyte userAttack, sbyte targetAttack,
sbyte expectedUserChange, sbyte expectedTargetChange)
{
// Arrange
var (script, move, user, target) = CreateTestSetup(new StatBoostStatisticSet(0, userAttack, 0, 0, 0, 0),
new StatBoostStatisticSet(0, targetAttack, 0, 0, 0, 0));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var userArgs = GetStatBoostArgs(user, Statistic.Attack);
var targetArgs = GetStatBoostArgs(target, Statistic.Attack);
await Assert.That(userArgs).IsNotNull();
await Assert.That((sbyte)userArgs![1]!).IsEqualTo(expectedUserChange);
await Assert.That(targetArgs).IsNotNull();
await Assert.That((sbyte)targetArgs![1]!).IsEqualTo(expectedTargetChange);
}
/// <summary>
/// Bulbapedia: "Heart Swap switches the user's stat stages with the target's stat stages." — every
/// battle stat stage (Attack, Defense, Special Attack, Special Defense, Speed) is exchanged at once.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AllBattleStatStages_AreSwapped()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(new StatBoostStatisticSet(0, 2, -1, 3, -4, 6),
new StatBoostStatisticSet(0, -2, 4, 0, 1, -6));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - each user change is (target - user), each target change is (user - target)
foreach (var (stat, userStage, targetStage) in new[]
{
(Statistic.Attack, (sbyte)2, (sbyte)-2),
(Statistic.Defense, (sbyte)-1, (sbyte)4),
(Statistic.SpecialAttack, (sbyte)3, (sbyte)0),
(Statistic.SpecialDefense, (sbyte)-4, (sbyte)1),
(Statistic.Speed, (sbyte)6, (sbyte)-6),
})
{
var userArgs = GetStatBoostArgs(user, stat);
var targetArgs = GetStatBoostArgs(target, stat);
await Assert.That(userArgs).IsNotNull();
await Assert.That((sbyte)userArgs![1]!).IsEqualTo((sbyte)(targetStage - userStage));
await Assert.That(targetArgs).IsNotNull();
await Assert.That((sbyte)targetArgs![1]!).IsEqualTo((sbyte)(userStage - targetStage));
}
}
/// <summary>
/// Bulbapedia: "Heart Swap switches the user's stat stages with the target's stat stages." — stat
/// stages include <see cref="Statistic.Evasion"/> and <see cref="Statistic.Accuracy"/>, which are
/// exchanged as well.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EvasionAndAccuracyStages_AreSwapped()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(
new StatBoostStatisticSet(0, 0, 0, 0, 0, 0) { Evasion = 2, Accuracy = -1 },
new StatBoostStatisticSet(0, 0, 0, 0, 0, 0) { Evasion = -3, Accuracy = 4 });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var userEvasionArgs = GetStatBoostArgs(user, Statistic.Evasion);
var userAccuracyArgs = GetStatBoostArgs(user, Statistic.Accuracy);
await Assert.That(userEvasionArgs).IsNotNull();
await Assert.That((sbyte)userEvasionArgs![1]!).IsEqualTo((sbyte)-5);
await Assert.That(userAccuracyArgs).IsNotNull();
await Assert.That((sbyte)userAccuracyArgs![1]!).IsEqualTo((sbyte)5);
}
/// <summary>
/// Bulbapedia: swapping identical stat stages is a net-zero exchange — neither Pokémon's stages are
/// modified.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EqualStages_NoStagesAreChanged()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(new StatBoostStatisticSet(0, 2, -1, 0, 3, 1),
new StatBoostStatisticSet(0, 2, -1, 0, 3, 1));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
}
/// <summary>
/// Bulbapedia: the exchange is unconditional — the changes are applied with force so clamping or
/// prevention effects cannot block the swap, and only the user's own change counts as
/// self-inflicted.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_ChangesAreForced()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(new StatBoostStatisticSet(0, 2, 0, 0, 0, 0),
new StatBoostStatisticSet(0, -1, 0, 0, 0, 0));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var userArgs = GetStatBoostArgs(user, Statistic.Attack);
var targetArgs = GetStatBoostArgs(target, Statistic.Attack);
await Assert.That((bool)userArgs![3]!).IsTrue(); // forced
await Assert.That((bool)targetArgs![3]!).IsTrue(); // forced
await Assert.That((bool)userArgs[2]!).IsTrue(); // self-inflicted for the user
await Assert.That((bool)targetArgs[2]!).IsFalse(); // not self-inflicted for the target
}
}

View File

@@ -0,0 +1,104 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="HeatCrash"/> move script.
/// Gen VII Bulbapedia behavior: "Heat Crash deals damage. Its power depends on the weight of both the user
/// and the target; the greater the difference between the user's weight and the target's weight, the
/// greater the power." The power table by the target's weight relative to the user is: more than 50% → 40,
/// 33.35%50% → 60, 25.01%33.34% → 80, 20.01%25% → 100, 20% or less → 120.
/// </summary>
public class HeatCrashTests
{
private static (HeatCrash script, IExecutingMove move, IPokemon target) CreateTestSetup(float userWeightInKg,
float targetWeightInKg)
{
var script = new HeatCrash();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.WeightInKg.Returns(userWeightInKg);
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
target.WeightInKg.Returns(targetWeightInKg);
return (script, move, target);
}
/// <summary>
/// Bulbapedia weight/power table: target more than 50% of the user's weight → 40,
/// 33.35%50% → 60, 25.01%33.34% → 80, 20.01%25% → 100, 20% or less → 120.
/// Each bracket is tested with values inside the bracket on both sides.
/// </summary>
[Test, Arguments(50f, 100f, (ushort)40), Arguments(100f, 100f, (ushort)40), Arguments(190f, 100f, (ushort)40),
Arguments(210f, 100f, (ushort)60), Arguments(290f, 100f, (ushort)60), Arguments(310f, 100f, (ushort)80),
Arguments(390f, 100f, (ushort)80), Arguments(410f, 100f, (ushort)100), Arguments(490f, 100f, (ushort)100),
Arguments(510f, 100f, (ushort)120), Arguments(1000f, 100f, (ushort)120)]
// target heavier than the user
// equal weight (100%)
// target at ~52.6%
// target at ~47.6%
// target at ~34.5%
// target at ~32.3%
// target at ~25.6%
// target at ~24.4%
// target at ~20.4%
// target at ~19.6%
// target at 10%
public async Task ChangeBasePower_WeightRatio_SetsBasePowerFromWeightTable(float userWeightInKg,
float targetWeightInKg, ushort expectedPower)
{
// Arrange
var (script, move, target) = CreateTestSetup(userWeightInKg, targetWeightInKg);
ushort basePower = 1;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expectedPower);
}
/// <summary>
/// Bulbapedia weight/power table boundaries: the brackets are "33.35% - 50%" → 60,
/// "25.01% - 33.34%" → 80, "20.01% - 25%" → 100 and "20% or less" → 120, so a target weighing
/// exactly 50%, one third, 25% or 20% of the user belongs to the higher-power bracket.
/// </summary>
[Test, Arguments(200f, 100f, (ushort)60), Arguments(300f, 100f, (ushort)80), Arguments(400f, 100f, (ushort)100),
Arguments(500f, 100f, (ushort)120)]
// target at exactly 50%
// target at exactly one third (33.33% ≤ 33.34%)
// target at exactly 25%
// target at exactly 20%
public async Task ChangeBasePower_ExactBracketBoundary_UsesHigherPowerBracket(float userWeightInKg,
float targetWeightInKg, ushort expectedPower)
{
// Arrange
var (script, move, target) = CreateTestSetup(userWeightInKg, targetWeightInKg);
ushort basePower = 1;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expectedPower);
}
/// <summary>
/// Bulbapedia: "Its power depends on the weight of both the user and the target" — the base power is
/// derived from the weight ratio alone, fully replacing the incoming base power value.
/// </summary>
[Test]
public async Task ChangeBasePower_HighIncomingBasePower_IsReplacedByWeightTableValue()
{
// Arrange
var (script, move, target) = CreateTestSetup(100f, 100f);
ushort basePower = 250;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)40);
}
}

View File

@@ -0,0 +1,199 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="HelpingHand"/> move script and the <see cref="HelpingHandEffect"/> volatile script it
/// applies to the ally.
/// Gen VII Bulbapedia behavior: "Helping Hand is an increased priority move that will increase the damage done by
/// the user's ally this turn by 50%. It will fail if there is no adjacent ally, or if the ally has already acted
/// this turn." and "If Helping Hand is used multiple times in a turn, all targeting the same Pokémon, the boosts
/// are cumulative."
/// </summary>
public class HelpingHandTests
{
/// <summary>
/// Creates an ally with a real <see cref="ScriptSet"/> as its <see cref="IPokemon.Volatile"/> set, so that
/// adding, stacking, and removing the <see cref="HelpingHandEffect"/> behaves as it would in battle.
/// </summary>
private static (IPokemon ally, ScriptSet volatiles) CreateAllyWithRealVolatileSet()
{
var ally = Substitute.For<IPokemon>();
var volatiles = new ScriptSet(ally);
// ScriptSet.Add runs the IScriptPreventVolatileAdd hook over the owner's scripts; give the mock a real
// iterator so that hook pass runs (empty of blockers here).
ally.GetScripts().Returns(_ => new ScriptIterator(new IEnumerable<ScriptContainer>[] { volatiles }));
ally.Volatile.Returns(volatiles);
return (ally, volatiles);
}
/// <summary>
/// Applies <see cref="IScriptChangeBasePower.ChangeBasePower"/> of every script in the ally's volatile set to
/// the given base power, as the battle engine would when the ally uses its move.
/// </summary>
private static ushort RunChangeBasePowerHooks(ScriptSet volatiles, ushort basePower)
{
var allyMove = Substitute.For<IExecutingMove>();
var opponent = Substitute.For<IPokemon>();
foreach (var container in volatiles)
{
if (container.Script is IScriptChangeBasePower changeBasePower)
changeBasePower.ChangeBasePower(allyMove, opponent, 0, ref basePower);
}
return basePower;
}
/// <summary>
/// Bulbapedia: "Helping Hand is an increased priority move that will increase the damage done by the user's
/// ally this turn by 50%." Using the move attaches the <see cref="HelpingHandEffect"/> volatile to the targeted
/// ally.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UsedOnAlly_AddsHelpingHandEffectToAlly()
{
// Arrange
var helpingHand = new HelpingHand();
var move = Substitute.For<IExecutingMove>();
var ally = Substitute.For<IPokemon>();
// Act
helpingHand.OnSecondaryEffect(move, ally, 0);
// Assert
var addCall = ally.Volatile.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Add");
await Assert.That(addCall).IsNotNull();
await Assert.That(addCall!.GetArguments()[0] is HelpingHandEffect).IsTrue();
}
/// <summary>
/// Bulbapedia: Helping Hand "will increase the damage done by the user's ally this turn by 50%."
/// The <see cref="HelpingHandEffect"/> multiplies the boosted move's base power by 1.5, truncating fractions.
/// </summary>
[Test, Arguments((ushort)100, (ushort)150), Arguments((ushort)60, (ushort)90), Arguments((ushort)65, (ushort)97),
Arguments((ushort)1, (ushort)1)]
public async Task ChangeBasePower_EffectActive_BasePowerIncreasedByFiftyPercent(ushort basePower,
ushort expectedPower)
{
// Arrange
var effect = new HelpingHandEffect();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
// Act
effect.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expectedPower);
}
/// <summary>
/// Technical test: boosting a very large base power clamps at <see cref="ushort.MaxValue"/> instead of
/// overflowing.
/// </summary>
[Test]
public async Task ChangeBasePower_LargeBasePower_ClampsAtMaxValue()
{
// Arrange
var effect = new HelpingHandEffect();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var basePower = ushort.MaxValue;
// Act
effect.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(ushort.MaxValue);
}
/// <summary>
/// Bulbapedia: "If Helping Hand is used multiple times in a turn, all targeting the same Pokémon, the boosts
/// are cumulative. The move power will increase by 125% if there are two Helping Hands".
/// Two Helping Hands on the same ally should therefore boost 100 base power to 225 (1.5 × 1.5).
/// </summary>
[Test]
public async Task ChangeBasePower_TwoHelpingHandsOnSameAlly_BoostsAreCumulative()
{
// Arrange
var helpingHand = new HelpingHand();
var move = Substitute.For<IExecutingMove>();
var (ally, volatiles) = CreateAllyWithRealVolatileSet();
// Act - two different Pokémon use Helping Hand on the same ally in the same turn
helpingHand.OnSecondaryEffect(move, ally, 0);
helpingHand.OnSecondaryEffect(move, ally, 0);
var boosted = RunChangeBasePowerHooks(volatiles, 100);
// Assert - 100 × 1.5 × 1.5 = 225
await Assert.That(boosted).IsEqualTo((ushort)225);
}
/// <summary>
/// Bulbapedia: "The move power will increase by ... 237.5% if there are three [Helping Hands]."
/// Three Helping Hands on the same ally should boost 100 base power to 337 (1.5³ = 3.375, truncated).
/// </summary>
[Test]
public async Task ChangeBasePower_ThreeHelpingHandsOnSameAlly_BoostsAreCumulative()
{
// Arrange
var helpingHand = new HelpingHand();
var move = Substitute.For<IExecutingMove>();
var (ally, volatiles) = CreateAllyWithRealVolatileSet();
// Act
helpingHand.OnSecondaryEffect(move, ally, 0);
helpingHand.OnSecondaryEffect(move, ally, 0);
helpingHand.OnSecondaryEffect(move, ally, 0);
var boosted = RunChangeBasePowerHooks(volatiles, 100);
// Assert - 100 × 1.5 × 1.5 × 1.5 = 337.5, truncated to 337
await Assert.That(boosted).IsEqualTo((ushort)337);
}
/// <summary>
/// Bulbapedia: Helping Hand increases "the damage done by the user's ally this turn" — the boost only lasts
/// for the turn it was used, so the <see cref="HelpingHandEffect"/> removes itself at the end of the turn.
/// </summary>
[Test]
public async Task OnEndTurn_TurnEnds_EffectRemovesItselfFromAlly()
{
// Arrange
var (ally, volatiles) = CreateAllyWithRealVolatileSet();
var helpingHand = new HelpingHand();
var move = Substitute.For<IExecutingMove>();
helpingHand.OnSecondaryEffect(move, ally, 0);
await Assert.That(volatiles.TryGet<HelpingHandEffect>(out var effect)).IsTrue();
// Act
effect!.OnEndTurn(ally, Substitute.For<IBattle>());
// Assert
await Assert.That(volatiles.TryGet<HelpingHandEffect>(out _)).IsFalse();
}
/// <summary>
/// Bulbapedia: the 50% boost applies "this turn" — after the effect has been removed at the end of the turn,
/// the ally's moves are no longer boosted.
/// </summary>
[Test]
public async Task ChangeBasePower_AfterEndOfTurn_BasePowerNoLongerBoosted()
{
// Arrange
var (ally, volatiles) = CreateAllyWithRealVolatileSet();
var helpingHand = new HelpingHand();
var move = Substitute.For<IExecutingMove>();
helpingHand.OnSecondaryEffect(move, ally, 0);
volatiles.TryGet<HelpingHandEffect>(out var effect);
// Act
effect!.OnEndTurn(ally, Substitute.For<IBattle>());
var boosted = RunChangeBasePowerHooks(volatiles, 100);
// Assert
await Assert.That(boosted).IsEqualTo((ushort)100);
}
}

View File

@@ -0,0 +1,121 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Status;
using PkmnLib.Static.Species;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Hex"/> move script.
/// Gen VII Bulbapedia behavior: Hex's base power "is 50 and will double to 100 when the target is affected by a
/// non-volatile status condition"; Generation VI onwards: "Hex's base power was increased to 65 and will double
/// to 130." Additionally, from the Comatose ability page: "The Pokémon with this Ability takes double damage from
/// Hex".
/// </summary>
public class HexTests
{
/// <summary>
/// Creates a fully mocked test setup where the target has the given (or no) non-volatile status in its
/// <see cref="IPokemon.StatusScript"/>.
/// </summary>
private static (Hex hex, IExecutingMove move, IPokemon target) CreateTestSetup(Script? status)
{
var hex = new Hex();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.StatusScript.Returns(status == null ? new ScriptContainer() : new ScriptContainer(status));
move.User.Returns(Substitute.For<IPokemon>());
return (hex, move, target);
}
/// <summary>
/// Every non-volatile status condition implemented in Gen 7.
/// </summary>
public static IEnumerable<Func<Script>> NonVolatileStatusScripts()
{
yield return () => new Burned();
yield return () => new Poisoned();
yield return () => new BadlyPoisoned();
yield return () => new Paralyzed();
yield return () => new Frozen();
yield return () => new Sleep();
}
/// <summary>
/// Bulbapedia: Hex "will double to 130 when the target is affected by a non-volatile status condition."
/// Every non-volatile status (burn, poison, bad poison, paralysis, freeze, sleep) doubles the base power.
/// </summary>
[Test, MethodDataSource(nameof(NonVolatileStatusScripts))]
public async Task ChangeBasePower_TargetHasNonVolatileStatus_BasePowerDoubles(Script status)
{
// Arrange
var (hex, move, target) = CreateTestSetup(status);
ushort basePower = 65;
// Act
hex.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)130);
}
/// <summary>
/// Bulbapedia: the base power only doubles "when the target is affected by a non-volatile status condition" —
/// against a target with no status the base power is unchanged.
/// </summary>
[Test]
public async Task ChangeBasePower_TargetHasNoStatus_BasePowerUnchanged()
{
// Arrange
var (hex, move, target) = CreateTestSetup(null);
ushort basePower = 65;
// Act
hex.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)65);
}
/// <summary>
/// Bulbapedia (Comatose ability page, Gen VII): "The Pokémon with this Ability takes double damage from Hex" —
/// a target with <see cref="IPokemon.ActiveAbility"/> Comatose gets the doubled base power even without a
/// status condition.
/// </summary>
[Test]
public async Task ChangeBasePower_TargetHasComatose_BasePowerDoubles()
{
// Arrange
var (hex, move, target) = CreateTestSetup(null);
var comatose = Substitute.For<IAbility>();
comatose.Name.Returns(new StringKey("comatose"));
target.ActiveAbility.Returns(comatose);
ushort basePower = 65;
// Act
hex.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)130);
}
/// <summary>
/// Technical test: doubling a very large base power clamps at <see cref="ushort.MaxValue"/> instead of
/// overflowing.
/// </summary>
[Test]
public async Task ChangeBasePower_StatusedTargetWithLargeBasePower_ClampsAtMaxValue()
{
// Arrange
var (hex, move, target) = CreateTestSetup(new Burned());
ushort basePower = ushort.MaxValue - 100;
// Act
hex.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(ushort.MaxValue);
}
}

View File

@@ -1,5 +1,6 @@
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Libraries;
@@ -7,25 +8,43 @@ using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="HiddenPower"/> move script.
/// Gen VII Bulbapedia behavior: "Hidden Power can be any type, other than Normal and Fairy, based on its user's
/// IVs." and "From Generation VI onward, its power is fixed at 60."
/// </summary>
public class HiddenPowerTests
{
public record TestCaseData(IndividualValueStatisticSet Ivs, StringKey ExpectedType, ushort ExpectedPower)
public record TestCaseData(IndividualValueStatisticSet Ivs, StringKey ExpectedType)
{
/// <inheritdoc />
public override string ToString() =>
$"Hidden Power type is {ExpectedType}, base power is {ExpectedPower} " +
$"Hidden Power type is {ExpectedType} " +
$"with IVs: HP {Ivs.Hp}, Atk {Ivs.Attack}, Def {Ivs.Defense}, SpA {Ivs.SpecialAttack}, SpD {Ivs.SpecialDefense}, Spe {Ivs.Speed}";
}
/// <summary>
/// Rows of the Bulbapedia type-determination formula: the type index is
/// (a + 2b + 4c + 8d + 16e + 32f) × 15 / 63 rounded down, where af are the least significant bits of the
/// HP, Attack, Defense, Speed, Special Attack, and Special Defense IVs, and index 0 is Fighting through
/// index 15 being Dark.
/// </summary>
public static IEnumerable<Func<TestCaseData>> HiddenPowerTestData()
{
yield return () => new TestCaseData(new IndividualValueStatisticSet(31, 31, 31, 31, 31, 31), "dark", 70);
yield return () => new TestCaseData(new IndividualValueStatisticSet(25, 2, 12, 5, 8, 17), "bug", 31);
yield return () => new TestCaseData(new IndividualValueStatisticSet(29, 19, 18, 22, 15, 28), "fire", 64);
// All-even IVs: formula value 0 -> the lowest possible type, Fighting.
yield return () => new TestCaseData(new IndividualValueStatisticSet(0, 0, 0, 0, 0, 0), "fighting");
// Only the Speed IV is odd: formula value 8 -> 8 * 15 / 63 = 1, Flying.
yield return () => new TestCaseData(new IndividualValueStatisticSet(0, 0, 0, 0, 0, 1), "flying");
// All-odd IVs: formula value 63 -> the highest possible type, Dark.
yield return () => new TestCaseData(new IndividualValueStatisticSet(31, 31, 31, 31, 31, 31), "dark");
yield return () => new TestCaseData(new IndividualValueStatisticSet(25, 2, 12, 5, 8, 17), "bug");
yield return () => new TestCaseData(new IndividualValueStatisticSet(29, 19, 18, 22, 15, 28), "fire");
}
[Test, MethodDataSource(nameof(HiddenPowerTestData))]
public async Task HiddenPower_ChangesType(TestCaseData test)
/// <summary>
/// Creates a type library holding all 18 Gen VII types, registered in the National Pokédex type order.
/// </summary>
private static TypeLibrary CreateTypeLibrary()
{
var typeLibrary = new TypeLibrary();
typeLibrary.RegisterType("normal");
@@ -46,7 +65,14 @@ public class HiddenPowerTests
typeLibrary.RegisterType("dragon");
typeLibrary.RegisterType("dark");
typeLibrary.RegisterType("fairy");
return typeLibrary;
}
/// <summary>
/// Creates a mocked executing move whose user has the given IVs and a library containing all 18 types.
/// </summary>
private static (IExecutingMove executingMove, IPokemon target) CreateTestSetup(IndividualValueStatisticSet ivs)
{
var executingMove = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
var target = Substitute.For<IPokemon>();
@@ -54,10 +80,22 @@ public class HiddenPowerTests
var staticLibrary = Substitute.For<IStaticLibrary>();
executingMove.User.Returns(user);
user.IndividualValues.Returns(test.Ivs);
user.IndividualValues.Returns(ivs);
user.Library.Returns(dynamicLibrary);
staticLibrary.Types.Returns(typeLibrary);
staticLibrary.Types.Returns(CreateTypeLibrary());
dynamicLibrary.StaticLibrary.Returns(staticLibrary);
return (executingMove, target);
}
/// <summary>
/// Bulbapedia: "Hidden Power can be any type, other than Normal and Fairy, based on its user's IVs."
/// The type index is (a + 2b + 4c + 8d + 16e + 32f) × 15 / 63 rounded down, where af are the least
/// significant bits of the HP, Attack, Defense, Speed, Special Attack, and Special Defense IVs.
/// </summary>
[Test, MethodDataSource(nameof(HiddenPowerTestData))]
public async Task HiddenPower_ChangesType(TestCaseData test)
{
var (executingMove, target) = CreateTestSetup(test.Ivs);
TypeIdentifier? moveType = new TypeIdentifier(1, "normal");
@@ -67,24 +105,42 @@ public class HiddenPowerTests
await Assert.That(moveType!.Value.Name).IsEqualTo(test.ExpectedType);
}
[Test, MethodDataSource(nameof(HiddenPowerTestData))]
public async Task HiddenPower_ChangesBasePower(TestCaseData test)
/// <summary>
/// Bulbapedia: "Hidden Power can be any type, other than Normal and Fairy, based on its user's IVs."
/// The type only depends on the least significant bit of each of the six IVs, so checking all 64 parity
/// combinations exhaustively proves the move can never be Normal or Fairy.
/// </summary>
[Test]
public async Task ChangeMoveType_AllIvParityCombinations_TypeIsNeverNormalOrFairy()
{
var executingMove = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
var target = Substitute.For<IPokemon>();
var dynamicLibrary = Substitute.For<IDynamicLibrary>();
var staticLibrary = Substitute.For<IStaticLibrary>();
for (byte combination = 0; combination < 64; combination++)
{
// Arrange - each bit of the combination becomes the least significant bit of one IV.
var ivs = new IndividualValueStatisticSet((byte)(combination & 0x01), (byte)((combination >> 1) & 0x01),
(byte)((combination >> 2) & 0x01), (byte)((combination >> 4) & 0x01), (byte)((combination >> 5) & 0x01),
(byte)((combination >> 3) & 0x01));
var (executingMove, target) = CreateTestSetup(ivs);
TypeIdentifier? moveType = new TypeIdentifier(1, "normal");
executingMove.User.Returns(user);
user.IndividualValues.Returns(test.Ivs);
user.Library.Returns(dynamicLibrary);
dynamicLibrary.StaticLibrary.Returns(staticLibrary);
// Act
var hiddenPower = new HiddenPower();
hiddenPower.ChangeMoveType(executingMove, target, 0, ref moveType);
// Assert
await Assert.That(moveType!.Value.Name).IsNotEqualTo(new StringKey("normal"));
await Assert.That(moveType!.Value.Name).IsNotEqualTo(new StringKey("fairy"));
}
}
/// <summary>
/// Bulbapedia: "From Generation VI onward, its power is fixed at 60." The pre-Gen-VI variable power formula
/// no longer applies, so the script must leave the base power of 60 from the move data untouched, regardless
/// of the user's IVs.
/// </summary>
[Test, MethodDataSource(nameof(HiddenPowerTestData))]
public async Task ChangeBasePower_AnyIvs_PowerRemainsFixedAtSixty(TestCaseData test)
{
var hiddenPower = new HiddenPower();
ushort power = 0;
hiddenPower.ChangeBasePower(executingMove, target, 0, ref power);
await Assert.That(power).IsEqualTo(test.ExpectedPower);
await Assert.That(hiddenPower).IsNotAssignableTo<IScriptChangeBasePower>();
}
}

View File

@@ -0,0 +1,238 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="HighJumpKick"/> move script.
/// Gen VII Bulbapedia behavior: if the move misses, the user takes crash damage. Generation V onwards:
/// "The crash damage is now (always) equal to half of the user's max HP, rounded down."
/// Crash damage also applies when the move is protected against (Generation II onwards: "The user will take
/// crash damage if Hi Jump Kick is protected against by a move such as Protect") and due to a target's type
/// immunity (Generation IV onwards: "The user can now crash due to a target's type immunity").
/// The script implements <see cref="IScriptOnAfterHits"/> and applies crash damage when none of the move's
/// hits executed with damage.
/// </summary>
public class HighJumpKickTests
{
/// <summary>
/// Creates a fully mocked test setup for High Jump Kick tests, with the given user max HP
/// (<see cref="IPokemon.BoostedStats"/>) and hit data. The engine calculates a hit's damage before the
/// accuracy and block checks, so a missed or blocked hit still carries a damage value but has
/// <see cref="IHitData.HasExecuted"/> set to false.
/// </summary>
private static (HighJumpKick script, IExecutingMove move, IPokemon target, IPokemon user) CreateTestSetup(
uint maxHp, params (bool hasExecuted, uint damage)[] hits)
{
var script = new HighJumpKick();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = hits.Select(h =>
{
var hit = Substitute.For<IHitData>();
hit.HasExecuted.Returns(h.hasExecuted);
hit.Damage.Returns(h.damage);
return hit;
}).ToArray();
move.Hits.Returns(hitData);
var user = Substitute.For<IPokemon>();
user.BoostedStats.Returns(new StatisticSet<uint>(maxHp, 0, 0, 0, 0, 0));
move.User.Returns(user);
return (script, move, target, user);
}
/// <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 (Generation V onwards): "The crash damage is now (always) equal to half of the user's max
/// HP, rounded down."
/// When the move misses, the hit never executes; after the hit loop the engine invokes
/// <see cref="IScriptOnAfterHits"/> and the user takes half of its own max HP as crash damage.
/// </summary>
[Test]
public async Task OnAfterHits_MoveMissed_UserTakesHalfMaxHpCrashDamage()
{
// Arrange - the missed hit has its would-be damage calculated but never executed.
var (script, move, target, user) = CreateTestSetup(200, (false, 400));
// Act
script.OnAfterHits(move, target);
// Assert
await Assert.That(GetDamageAmount(user)!.Value).IsEqualTo(100u);
}
/// <summary>
/// Bulbapedia (Generation V onwards): "The crash damage is now (always) equal to half of the user's max
/// HP, rounded down."
/// Odd max HP values are rounded down when halved.
/// </summary>
[Test, Arguments(200u, 100u), Arguments(301u, 150u), Arguments(75u, 37u)]
public async Task OnAfterHits_MoveMissed_CrashDamageIsHalfMaxHpRoundedDown(uint maxHp, uint expectedCrash)
{
// Arrange
var (script, move, target, user) = CreateTestSetup(maxHp, (false, 400));
// Act
script.OnAfterHits(move, target);
// Assert
await Assert.That(GetDamageAmount(user)!.Value).IsEqualTo(expectedCrash);
}
/// <summary>
/// With a max HP of 1 the crash damage rounds down to 0, and the script does not invoke a zero-damage
/// call on the user at all.
/// </summary>
[Test]
public async Task OnAfterHits_MaxHpOne_NoDamageCallOnUser()
{
// Arrange
var (script, move, target, user) = CreateTestSetup(1, (false, 400));
// Act
script.OnAfterHits(move, target);
// Assert
await Assert.That(GetDamageAmount(user)).IsNull();
}
/// <summary>
/// Bulbapedia (Generation V onwards): "The crash damage is now (always) equal to half of the user's max
/// HP, rounded down."
/// Since Generation V the crash damage no longer depends on the damage the move would have dealt (that
/// was the Generation III/IV mechanic), so a low would-be damage still results in half the user's max HP.
/// </summary>
[Test]
public async Task OnAfterHits_LowWouldBeDamage_CrashDamageStillHalfMaxHp()
{
// Arrange
var (script, move, target, user) = CreateTestSetup(200, (false, 10));
// Act
script.OnAfterHits(move, target);
// Assert
await Assert.That(GetDamageAmount(user)!.Value).IsEqualTo(100u);
}
/// <summary>
/// When the move hits and deals damage, the user takes no crash damage.
/// </summary>
[Test]
public async Task OnAfterHits_MoveHitWithDamage_NoCrashDamage()
{
// Arrange
var (script, move, target, user) = CreateTestSetup(200, (true, 400));
// Act
script.OnAfterHits(move, target);
// Assert
await Assert.That(GetDamageAmount(user)).IsNull();
}
/// <summary>
/// Bulbapedia (Generation V onwards): "The crash damage is now (always) equal to half of the user's max
/// HP, rounded down."
/// Crash damage is indirect damage, not move damage, so it uses <see cref="DamageSource.Misc"/>.
/// </summary>
[Test]
public async Task OnAfterHits_MoveMissed_UsesMiscDamageSource()
{
// Arrange
var (script, move, target, user) = CreateTestSetup(200, (false, 400));
// Act
script.OnAfterHits(move, target);
// Assert
await Assert.That(GetDamageSource(user)!.Value).IsEqualTo(DamageSource.Misc);
}
/// <summary>
/// Bulbapedia (Generation II onwards): "The user will take crash damage if Hi Jump Kick is protected
/// against by a move such as Protect".
/// When the hit is blocked by protection, the engine (MoveTurnExecutor) breaks out of the hit loop
/// without executing the hit, then invokes <see cref="IScriptOnAfterHits"/>; the blocked hit's would-be
/// damage was already calculated but the hit never executed, so crash damage applies.
/// </summary>
[Test]
public async Task OnAfterHits_MoveBlockedByProtect_UserTakesCrashDamage()
{
// Arrange - a blocked hit carries its calculated would-be damage but never executed.
var (script, move, target, user) = CreateTestSetup(200, (false, 400));
// Act
script.OnAfterHits(move, target);
// Assert
var damage = GetDamageAmount(user);
await Assert.That(damage).IsNotNull();
await Assert.That(damage!.Value).IsEqualTo(100u);
}
/// <summary>
/// Bulbapedia (Generation IV onwards): "The user can now crash due to a target's type immunity", with the
/// Generation V onwards amount: "The crash damage is now (always) equal to half of the user's max HP,
/// rounded down."
/// Against a Ghost-type target the hit deals no damage (effectiveness 0), so the engine never marks the
/// hit as executed; <see cref="IScriptOnAfterHits"/> still runs and applies crash damage.
/// </summary>
[Test]
public async Task OnAfterHits_TargetImmuneToMove_UserTakesCrashDamage()
{
// Arrange - a Ghost-type target: the hit deals no damage and is never marked as executed.
var (script, move, target, user) = CreateTestSetup(200, (false, 0));
// Act
script.OnAfterHits(move, target);
// Assert
var damage = GetDamageAmount(user);
await Assert.That(damage).IsNotNull();
await Assert.That(damage!.Value).IsEqualTo(100u);
}
/// <summary>
/// A miss caused by the target being in the semi-invulnerable turn of a move such as Fly or Dig is still
/// a miss, so per Bulbapedia (Generation V onwards) the user takes crash damage "equal to half of the
/// user's max HP, rounded down." For a semi-invulnerable target
/// (<see cref="IScriptIsInvulnerableToMove"/>) the engine skips the hit loop entirely and invokes
/// <see cref="IScriptOnAfterHits"/> before returning, so the hit data is left untouched (not executed,
/// no damage calculated) and the script applies crash damage.
/// </summary>
[Test]
public async Task OnAfterHits_MoveAvoidedBySemiInvulnerableTarget_UserTakesCrashDamage()
{
// Arrange - the hit loop never ran, so the hit was never executed and carries no damage.
var (script, move, target, user) = CreateTestSetup(200, (false, 0));
// Act
script.OnAfterHits(move, target);
// Assert
var damage = GetDamageAmount(user);
await Assert.That(damage).IsNotNull();
await Assert.That(damage!.Value).IsEqualTo(100u);
}
}

View File

@@ -0,0 +1,390 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Common;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Plugin.Gen7.Scripts.Side;
using PkmnLib.Static;
using PkmnLib.Static.Species;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="HyperspaceFury"/> move script.
/// Gen VII Bulbapedia behavior: "Hyperspace Fury inflicts damage, then lowers the user's Defense stat by one
/// stage." "It bypasses accuracy checks to always hit, unless the target is in the semi-invulnerable turn of
/// a move such as Dig or Fly." "It will also hit the target even if it is protected by Protect, Detect,
/// Spiky Shield, King's Shield, Mat Block, or Baneful Bunker, and lifts the effects of those moves for the
/// remainder of the turn. It also removes the effects of Quick Guard, Wide Guard, and Crafty Shield from the
/// target's side of the field." "It hits through a substitute." "Hyperspace Fury can only be successfully
/// used by Hoopa as Hoopa Unbound (or a Pokémon that has transformed into Hoopa Unbound)."
/// </summary>
public class HyperspaceFuryTests
{
/// <summary>
/// Creates a fully mocked test setup where the target has a real <see cref="ScriptSet"/> as its volatile
/// script set.
/// </summary>
private static (HyperspaceFury script, IExecutingMove move, IPokemon target, IPokemon user, IScriptSet volatileSet)
CreateTestSetup()
{
var script = new HyperspaceFury();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet volatileSet = new ScriptSet(target);
target.Volatile.Returns(volatileSet);
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
return (script, move, target, user, volatileSet);
}
/// <summary>
/// Extends the test setup with a battle side that has a real <see cref="ScriptSet"/> as its volatile
/// scripts, so side-wide protections such as <see cref="QuickGuardEffect"/> can be attached.
/// </summary>
private static IScriptSet AttachSide(IPokemon target)
{
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet sideScripts = new ScriptSet(side);
side.VolatileScripts.Returns(sideScripts);
var battle = Substitute.For<IBattle>();
battle.Sides.Returns(new[] { side });
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
battleData.SideIndex.Returns((byte)0);
battleData.BattleSide.Returns(side);
target.BattleData.Returns(battleData);
return sideScripts;
}
/// <summary>
/// Bulbapedia: "It will also hit the target even if it is protected by Protect, Detect, Spiky Shield,
/// King's Shield, Mat Block, or Baneful Bunker, and lifts the effects of those moves for the remainder
/// of the turn." — the target's <see cref="ProtectionEffectScript"/> (Protect/Detect) is removed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetUsedProtect_ProtectionEffectRemoved()
{
// Arrange
var (script, move, target, _, volatileSet) = CreateTestSetup();
volatileSet.Add(new ProtectionEffectScript());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<ProtectionEffectScript>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "It will also hit the target even if it is protected by Protect, Detect, Spiky Shield,
/// King's Shield, Mat Block, or Baneful Bunker, and lifts the effects of those moves for the remainder
/// of the turn." — a target under Spiky Shield has its <see cref="SpikyShieldEffect"/> removed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetUnderSpikyShield_ProtectionEffectRemoved()
{
// Arrange
var (script, move, target, _, volatileSet) = CreateTestSetup();
volatileSet.Add(new SpikyShieldEffect());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<SpikyShieldEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "It will also hit the target even if it is protected by Protect, Detect, Spiky Shield,
/// King's Shield, Mat Block, or Baneful Bunker, and lifts the effects of those moves for the remainder
/// of the turn." — a target under King's Shield has its <see cref="KingsShieldEffect"/> effect removed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetUnderKingsShield_ProtectionEffectRemoved()
{
// Arrange
var (script, move, target, _, volatileSet) = CreateTestSetup();
volatileSet.Add(new KingsShieldEffect());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<KingsShieldEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "It will also hit the target even if it is protected by Protect, Detect, Spiky Shield,
/// King's Shield, Mat Block, or Baneful Bunker, and lifts the effects of those moves for the remainder
/// of the turn." — a target under Baneful Bunker has its <see cref="BanefulBunkerEffect"/> removed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetUnderBanefulBunker_ProtectionEffectRemoved()
{
// Arrange
var (script, move, target, _, volatileSet) = CreateTestSetup();
volatileSet.Add(new BanefulBunkerEffect());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<BanefulBunkerEffect>())).IsFalse();
}
/// <summary>
/// With no protection effect on the target, the secondary effect simply does nothing and does not throw.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetNotProtected_DoesNothing()
{
// Arrange
var (script, move, target, _, volatileSet) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Count).IsEqualTo(0);
}
/// <summary>
/// Bulbapedia: "Hyperspace Fury inflicts damage, then lowers the user's Defense stat by one stage."
/// The self-inflicted Defense drop is applied via <see cref="IPokemon.ChangeStatBoost"/>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DamageDealt_LowersUserDefenseByOneStage()
{
// Arrange
var (script, move, target, user, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - the user received a self-inflicted ChangeStatBoost(Defense, -1) call.
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
await Assert.That(call).IsNotNull();
var arguments = call!.GetArguments();
await Assert.That((Statistic)arguments[0]!).IsEqualTo(Statistic.Defense);
await Assert.That((sbyte)arguments[1]!).IsEqualTo((sbyte)-1);
await Assert.That((bool)arguments[2]!).IsTrue();
}
/// <summary>
/// Gives the mocked user an explicit species and form name.
/// </summary>
private static void SetUserSpeciesForm(IPokemon user, string speciesName, string formName)
{
var species = Substitute.For<ISpecies>();
species.Name.Returns(new StringKey(speciesName));
var form = Substitute.For<IForm>();
form.Name.Returns(new StringKey(formName));
user.Species.Returns(species);
user.Form.Returns(form);
}
/// <summary>
/// Bulbapedia: "Hyperspace Fury can only be successfully used by Hoopa as Hoopa Unbound (or a Pokémon
/// that has transformed into Hoopa Unbound)." — used by any other Pokémon, the move fails
/// ("But &lt;Pokémon&gt; can't use the move!").
/// </summary>
[Test]
public async Task FailMove_UserNotHoopaUnbound_MoveFails()
{
// Arrange - the user is a different species entirely.
var (script, move, _, user, _) = CreateTestSetup();
SetUserSpeciesForm(user, "garchomp", "default");
var fail = false;
// Act
((IScriptFailMove)script).FailMove(move, ref fail);
// Assert
await Assert.That(fail).IsTrue();
}
/// <summary>
/// Bulbapedia: "Hyperspace Fury can only be successfully used by Hoopa as Hoopa Unbound" — used by
/// Hoopa in its Confined (default) form, the move fails ("But Hoopa can't use it the way it is now!").
/// </summary>
[Test]
public async Task FailMove_UserHoopaConfined_MoveFails()
{
// Arrange - the user is Hoopa, but in its default (Confined) form.
var (script, move, _, user, _) = CreateTestSetup();
SetUserSpeciesForm(user, "hoopa", "default");
var fail = false;
// Act
((IScriptFailMove)script).FailMove(move, ref fail);
// Assert
await Assert.That(fail).IsTrue();
}
/// <summary>
/// Bulbapedia: "Hyperspace Fury can only be successfully used by Hoopa as Hoopa Unbound" — used by
/// Hoopa Unbound itself, the move does not fail.
/// </summary>
[Test]
public async Task FailMove_UserHoopaUnbound_MoveDoesNotFail()
{
// Arrange - the user is Hoopa in its Unbound form.
var (script, move, _, user, _) = CreateTestSetup();
SetUserSpeciesForm(user, "hoopa", "unbound");
var fail = false;
// Act
((IScriptFailMove)script).FailMove(move, ref fail);
// Assert
await Assert.That(fail).IsFalse();
}
/// <summary>
/// Bulbapedia: "It also removes the effects of Quick Guard, Wide Guard, and Crafty Shield from the
/// target's side of the field." — the <see cref="QuickGuardEffect"/> is removed from the target's side.
/// </summary>
[Test]
public async Task OnSecondaryEffect_QuickGuardOnTargetSide_SideEffectRemoved()
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup();
var sideScripts = AttachSide(target);
sideScripts.Add(new QuickGuardEffect());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName<QuickGuardEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "It also removes the effects of Quick Guard, Wide Guard, and Crafty Shield from the
/// target's side of the field." — the <see cref="WideGuardEffect"/> is removed from the target's side.
/// </summary>
[Test]
public async Task OnSecondaryEffect_WideGuardOnTargetSide_SideEffectRemoved()
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup();
var sideScripts = AttachSide(target);
sideScripts.Add(new WideGuardEffect());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName<WideGuardEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "It also removes the effects of Quick Guard, Wide Guard, and Crafty Shield from the
/// target's side of the field." — the <see cref="CraftyShieldEffect"/> is removed from the target's side.
/// </summary>
[Test]
public async Task OnSecondaryEffect_CraftyShieldOnTargetSide_SideEffectRemoved()
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup();
var sideScripts = AttachSide(target);
sideScripts.Add(new CraftyShieldEffect());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName<CraftyShieldEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "It will also hit the target even if it is protected by Protect, Detect, Spiky Shield,
/// King's Shield, Mat Block, or Baneful Bunker, and lifts the effects of those moves for the remainder
/// of the turn." — Mat Block is a side-wide effect in this implementation
/// (<see cref="MatBlockEffect"/>), and it is lifted as well.
/// </summary>
[Test]
public async Task OnSecondaryEffect_MatBlockOnTargetSide_SideEffectRemoved()
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup();
var sideScripts = AttachSide(target);
sideScripts.Add(new MatBlockEffect());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName<MatBlockEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "It bypasses accuracy checks to always hit, unless the target is in the semi-invulnerable
/// turn of a move such as Dig or Fly."
/// Integration check: the Gen7 data gives hyperspace_fury an accuracy of 255, which the engine treats as
/// an unmodifiable always-hit.
/// </summary>
[Test]
public async Task MoveData_HyperspaceFury_BypassesAccuracyChecks()
{
// Arrange
var library = LibraryHelpers.LoadLibrary();
// Act
var found = library.StaticLibrary.Moves.TryGet("hyperspace_fury", out var moveData);
// Assert
await Assert.That(found).IsTrue();
await Assert.That(moveData!.Accuracy).IsEqualTo((byte)255);
}
/// <summary>
/// Bulbapedia: "It hits through a substitute."
/// Integration check: the Gen7 data gives hyperspace_fury the <see cref="MoveFlags.IgnoreSubstitute"/>
/// flag.
/// </summary>
[Test]
public async Task MoveData_HyperspaceFury_IgnoresSubstitute()
{
// Arrange
var library = LibraryHelpers.LoadLibrary();
// Act
var found = library.StaticLibrary.Moves.TryGet("hyperspace_fury", out var moveData);
// Assert
await Assert.That(found).IsTrue();
await Assert.That(moveData!.HasFlag(MoveFlags.IgnoreSubstitute)).IsTrue();
}
/// <summary>
/// Bulbapedia: "It will also hit the target even if it is protected by Protect, Detect, Spiky Shield,
/// King's Shield, Mat Block, or Baneful Bunker".
/// Integration check: <see cref="ProtectionEffectScript.BlockIncomingHit"/> blocks any move carrying the
/// <see cref="MoveFlags.Protect"/> flag before <see cref="HyperspaceFury.OnSecondaryEffect"/> can run,
/// so hyperspace_fury's data must not carry that flag for the move to hit protected targets.
/// </summary>
[Test]
public async Task MoveData_HyperspaceFury_NotBlockedByProtection()
{
// Arrange
var library = LibraryHelpers.LoadLibrary();
// Act
var found = library.StaticLibrary.Moves.TryGet("hyperspace_fury", out var moveData);
// Assert
await Assert.That(found).IsTrue();
await Assert.That(moveData!.HasFlag(MoveFlags.Protect)).IsFalse();
}
}

View File

@@ -0,0 +1,226 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="IceBall"/> move script and its <see cref="IceBallEffect"/> volatile.
/// Gen VII Bulbapedia behavior (Generations III to VII): "Ice Ball inflicts damage over five turns, doubling
/// in power for each consecutive hit; the power will reset after five turns or if Ice Ball is interrupted."
/// "The move doubles its power further if the user previously employed Defense Curl."
/// </summary>
public class IceBallTests
{
/// <summary>
/// Creates a fully mocked test setup for Ice Ball tests. The user gets a real <see cref="ScriptSet"/> as
/// its volatile set, so the effect applied by the move can be inspected.
/// </summary>
private static (IceBall script, IExecutingMove move, IPokemon user, IPokemon target, ScriptSet userVolatile)
CreateTestSetup()
{
var script = new IceBall();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var userVolatile = new ScriptSet(user);
user.Volatile.Returns(userVolatile);
move.User.Returns(user);
var moveData = Substitute.For<PkmnLib.Static.Moves.IMoveData>();
moveData.Name.Returns(new StringKey("ice_ball"));
move.UseMove.Returns(moveData);
var target = Substitute.For<IPokemon>();
return (script, move, user, target, userVolatile);
}
/// <summary>
/// Bulbapedia: "Ice Ball inflicts damage over five turns". On the first use there is no accumulated
/// consecutive-hit bonus yet, so the base power is unchanged.
/// </summary>
[Test]
public async Task ChangeBasePower_NoIceBallEffect_BasePowerUnchanged()
{
// Arrange
var (script, move, _, target, _) = CreateTestSetup();
ushort basePower = 30;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)30);
}
/// <summary>
/// Bulbapedia: Ice Ball inflicts damage "doubling in power for each consecutive hit". With a
/// <see cref="IceBallEffect.TurnCount"/> of n, the power is multiplied by 2^n.
/// </summary>
[Test, Arguments(0, (ushort)30), Arguments(1, (ushort)60), Arguments(2, (ushort)120), Arguments(3, (ushort)240),
Arguments(4, (ushort)480)]
public async Task ChangeBasePower_ConsecutiveHits_PowerDoublesPerHit(int turnCount, ushort expectedPower)
{
// Arrange
var (script, move, user, target, userVolatile) = CreateTestSetup();
userVolatile.Add(new IceBallEffect(user, "ice_ball") { TurnCount = turnCount });
ushort basePower = 30;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expectedPower);
}
/// <summary>
/// Technical test: a doubled base power that would overflow the ushort range is clamped to
/// <see cref="ushort.MaxValue"/> instead of wrapping around.
/// </summary>
[Test]
public async Task ChangeBasePower_MultiplicationOverflows_ClampsToUShortMax()
{
// Arrange
var (script, move, user, target, userVolatile) = CreateTestSetup();
userVolatile.Add(new IceBallEffect(user, "ice_ball") { TurnCount = 4 });
ushort basePower = 50000;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(ushort.MaxValue);
}
/// <summary>
/// Bulbapedia: "Ice Ball inflicts damage over five turns" — the first use starts the multi-turn effect
/// by attaching the <see cref="IceBallEffect"/> volatile to the user, with no consecutive hits yet.
/// </summary>
[Test]
public async Task OnSecondaryEffect_FirstUse_AddsIceBallEffectToUser()
{
// Arrange
var (script, move, _, target, userVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var effect = userVolatile.Get<IceBallEffect>();
await Assert.That(effect).IsNotNull();
await Assert.That(effect!.TurnCount).IsEqualTo(0);
}
/// <summary>
/// Bulbapedia: the power doubles "for each consecutive hit" — every hit after the first increments the
/// consecutive-hit counter on the existing <see cref="IceBallEffect"/>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EffectAlreadyPresent_IncrementsTurnCount()
{
// Arrange
var (script, move, user, target, userVolatile) = CreateTestSetup();
var effect = new IceBallEffect(user, "ice_ball");
userVolatile.Add(effect);
// Act
effect.OnEndTurn(user, move.Battle);
// Assert
await Assert.That(effect.TurnCount).IsEqualTo(1);
}
/// <summary>
/// Bulbapedia: "doubling in power for each consecutive hit" — the third consecutive hit must have four
/// times the base power (30 → 60 → 120). This simulates the actual per-turn flow: hit, end of turn,
/// next hit.
/// </summary>
[Test]
public async Task ChangeBasePower_ThirdConsecutiveHit_PowerIsQuadrupled()
{
// Arrange
var (script, move, user, target, userVolatile) = CreateTestSetup();
var battle = Substitute.For<IBattle>();
// Act - turn 1: first hit, then end of turn
script.OnSecondaryEffect(move, target, 0);
var effect = userVolatile.Get<IceBallEffect>()!;
effect.OnEndTurn(user, battle);
// Turn 2: second hit doubles the power
ushort secondHitPower = 30;
script.ChangeBasePower(move, target, 0, ref secondHitPower);
await Assert.That(secondHitPower).IsEqualTo((ushort)60);
script.OnSecondaryEffect(move, target, 0);
effect.OnEndTurn(user, battle);
// Turn 3: third hit quadruples the power
ushort thirdHitPower = 30;
script.ChangeBasePower(move, target, 0, ref thirdHitPower);
// Assert
await Assert.That(thirdHitPower).IsEqualTo((ushort)120);
}
/// <summary>
/// Bulbapedia: "The move doubles its power further if the user previously employed Defense Curl."
/// The doubling is implemented by the <see cref="DefenseCurlEffect"/> volatile on the user, whose own
/// <see cref="DefenseCurlEffect.ChangeBasePower"/> hook runs alongside the move script's.
/// </summary>
[Test]
public async Task ChangeBasePower_UserUsedDefenseCurl_PowerIsDoubled()
{
// Arrange
var (script, move, _, target, userVolatile) = CreateTestSetup();
var defenseCurlEffect = new DefenseCurlEffect();
userVolatile.Add(defenseCurlEffect);
ushort basePower = 30;
// Act - the battle engine runs the move script's hook and the user's volatile hooks
script.ChangeBasePower(move, target, 0, ref basePower);
defenseCurlEffect.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)60);
}
/// <summary>
/// Bulbapedia: "the power will reset ... if Ice Ball is interrupted." A miss interrupts the sequence,
/// removing the <see cref="IceBallEffect"/> from the user.
/// </summary>
[Test]
public async Task OnMoveMiss_MoveMisses_EffectRemovesItself()
{
// Arrange
var (_, move, user, target, userVolatile) = CreateTestSetup();
var effect = new IceBallEffect(user, "ice_ball");
userVolatile.Add(effect);
// Act
effect.OnMoveMiss(move, target);
// Assert
await Assert.That(userVolatile.Contains(new StringKey("ice_ball"))).IsFalse();
}
/// <summary>
/// Bulbapedia: "the power will reset after five turns" — once the five-turn sequence has completed, the
/// <see cref="IceBallEffect"/> removes itself from the user.
/// </summary>
[Test]
public async Task OnEndTurn_AfterFiveCompletedTurns_EffectRemovesItself()
{
// Arrange
var (_, _, user, _, userVolatile) = CreateTestSetup();
var effect = new IceBallEffect(user, "ice_ball");
userVolatile.Add(effect);
var battle = Substitute.For<IBattle>();
// Act - six end-of-turn ticks: five turns of use, then the reset
for (var i = 0; i < 6; i++)
effect.OnEndTurn(user, battle);
// Assert
await Assert.That(userVolatile.Contains(new StringKey("ice_ball"))).IsFalse();
}
}

View File

@@ -0,0 +1,270 @@
using PkmnLib.Dynamic.Events;
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.Pokemon;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="IceBurn"/> move script, a two-turn charge move using
/// <see cref="RequireChargeEffect"/>.
/// Gen VII Bulbapedia behavior: "Ice Burn deals damage and has a 30% chance of burning the target." On the
/// initial turn, the user enters a charging state while "cloaked in a freezing air"; the actual damage occurs
/// on the subsequent turn. "A Power Herb item can bypass the charging phase entirely."
/// </summary>
public class IceBurnTests
{
/// <summary>
/// Test helper script that bypasses the charge turn through the
/// <see cref="CustomTriggers.BypassChargeMove"/> custom trigger, the same way the Power Herb item
/// script does.
/// </summary>
[Script(ScriptCategory.Pokemon, "test_charge_bypasser")]
private class ChargeBypasser : Script, IScriptCustomTrigger
{
public void CustomTrigger(StringKey eventName, ICustomTriggerArgs args)
{
if (eventName == CustomTriggers.BypassChargeMove && args is CustomTriggers.BypassChargeMoveArgs bypassArgs)
bypassArgs.Bypass = true;
}
}
/// <summary>
/// Creates a fully mocked test setup for Ice Burn tests. The user gets a real <see cref="ScriptSet"/> as
/// its volatile set, so the charge volatile added by the move can be inspected afterwards.
/// </summary>
private static (IceBurn script, IExecutingMove move, IPokemon user, ScriptSet userVolatile, IBattleRandom random,
EventHook eventHook) CreateTestSetup()
{
var script = new IceBurn();
var move = Substitute.For<IExecutingMove>();
move.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var user = Substitute.For<IPokemon>();
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var userVolatile = new ScriptSet(user);
user.Volatile.Returns(userVolatile);
move.User.Returns(user);
var eventHook = new EventHook();
var random = Substitute.For<IBattleRandom>();
var battle = Substitute.For<IBattle>();
battle.EventHook.Returns(eventHook);
battle.Random.Returns(random);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
user.BattleData.Returns(battleData);
return (script, move, user, userVolatile, random, eventHook);
}
/// <summary>
/// Helper to check whether a Pokémon received any SetStatus call. (Checked via ReceivedCalls, as arg
/// matchers cannot be used for <see cref="EventBatchId"/> parameters.)
/// </summary>
private static bool ReceivedSetStatus(IPokemon pokemon) =>
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus");
/// <summary>
/// Bulbapedia: on the initial turn, the user enters a charging state; the actual damage occurs on the
/// subsequent turn. The first use is prevented from executing.
/// </summary>
[Test]
public async Task PreventMove_FirstUse_PreventsMoveForChargeTurn()
{
// Arrange
var (script, move, _, _, _, _) = CreateTestSetup();
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: the user enters a charging state on the first turn. The charge turn attaches the
/// <see cref="RequireChargeEffect"/> volatile to the user.
/// </summary>
[Test]
public async Task PreventMove_FirstUse_AddsRequireChargeEffectToUser()
{
// Arrange
var (script, move, _, userVolatile, _, _) = CreateTestSetup();
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<RequireChargeEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: the user is "cloaked in a freezing air" on the charge turn — the charge turn announces
/// itself through a <see cref="DialogEvent"/> so the battle log can show the charging message.
/// </summary>
[Test]
public async Task PreventMove_FirstUse_FiresBeganChargingDialogEvent()
{
// Arrange
var (script, move, user, _, _, eventHook) = CreateTestSetup();
DialogEvent? captured = null;
eventHook.Handler += (_, args) =>
{
if (args is DialogEvent dialogEvent)
captured = dialogEvent;
};
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(captured).IsNotNull();
await Assert.That(captured!.Message).IsEqualTo("began_charging");
var userParameter = captured!.Parameters?.GetValueOrDefault("user");
await Assert.That(ReferenceEquals(userParameter, user)).IsTrue();
}
/// <summary>
/// Bulbapedia: "The actual damage occurs on the subsequent turn." When the user already carries the
/// charge volatile from the previous turn, the move is not prevented again.
/// </summary>
[Test]
public async Task PreventMove_UserAlreadyCharged_DoesNotPreventMove()
{
// Arrange
var (script, move, user, userVolatile, _, _) = CreateTestSetup();
userVolatile.Add(script.CreateVolatile(user));
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// The attack executes on the second turn, ending the charging state: once the move executes, the
/// <see cref="RequireChargeEffect"/> is removed from the user.
/// </summary>
[Test]
public async Task OnBeforeMove_ChargeCompleted_RemovesRequireChargeEffect()
{
// Arrange
var (script, move, user, userVolatile, _, _) = CreateTestSetup();
userVolatile.Add(script.CreateVolatile(user));
// Act
script.OnBeforeMove(move);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<RequireChargeEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "Ice Burn deals damage and has a 30% chance of burning the target." When the burn roll
/// succeeds, the target is burned by the user.
/// </summary>
[Test]
public void OnSecondaryEffect_BurnRollSucceeds_TargetIsBurned()
{
// Arrange
var (script, move, user, _, random, _) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
random.EffectChance(30, move, target, 0).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.Received(1).SetStatus("burned", user);
}
/// <summary>
/// Bulbapedia: "has a 30% chance of burning the target." The burn roll is made with a 30 percent chance.
/// </summary>
[Test]
public void OnSecondaryEffect_BurnRoll_UsesThirtyPercentChance()
{
// Arrange
var (script, move, _, _, random, _) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
random.Received(1).EffectChance(30, move, target, 0);
}
/// <summary>
/// Bulbapedia: "has a 30% chance of burning the target." When the burn roll fails, the target is not
/// burned.
/// </summary>
[Test]
public async Task OnSecondaryEffect_BurnRollFails_TargetIsNotBurned()
{
// Arrange
var (script, move, _, _, random, _) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
random.EffectChance(30, move, target, 0).Returns(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedSetStatus(target)).IsFalse();
}
/// <summary>
/// Technical test: if the user has no <see cref="IPokemon.BattleData"/>, the secondary effect does
/// nothing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserHasNoBattleData_DoesNothing()
{
// Arrange
var script = new IceBurn();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedSetStatus(target)).IsFalse();
}
/// <summary>
/// Bulbapedia: "A Power Herb item can bypass the charging phase entirely." The bypass flows through the
/// <see cref="CustomTriggers.BypassChargeMove"/> custom trigger; a script that sets the bypass flag
/// (as the Power Herb item script does) lets the user attack on the first turn without charging.
/// </summary>
[Test]
public async Task PreventMove_ChargeBypassedByTrigger_MoveIsNotPrevented()
{
// Arrange
var (script, move, _, _, _, _) = CreateTestSetup();
move.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>
{
new ScriptContainer(new ChargeBypasser()),
}));
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
}

View File

@@ -0,0 +1,224 @@
using PkmnLib.Dynamic.Events;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="IceFang"/> move script.
/// Gen VII Bulbapedia behavior (Generation IV base text, unchanged through Gen VII): "Ice Fang deals damage
/// and has a 10% chance of freezing the opponent. It also has an independent 10% chance of causing the target
/// to flinch, if the user attacks before the target."
/// </summary>
public class IceFangTests
{
/// <summary>
/// Creates a fully mocked test setup for Ice Fang tests. The target has not yet moved this turn when
/// <paramref name="queue"/> contains a choice for it.
/// </summary>
private static (IceFang script, IExecutingMove move, IPokemon user, IPokemon target, IBattleRandom random,
IScriptSet targetVolatile) CreateTestSetup(BattleChoiceQueue? queue)
{
var script = new IceFang();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var random = Substitute.For<IBattleRandom>();
var battle = Substitute.For<IBattle>();
battle.Random.Returns(random);
battle.ChoiceQueue.Returns(queue);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
var targetVolatile = Substitute.For<IScriptSet>();
target.Volatile.Returns(targetVolatile);
return (script, move, user, target, random, targetVolatile);
}
/// <summary>
/// Creates a choice queue containing a single yet-to-execute move choice for the given Pokémon.
/// </summary>
private static BattleChoiceQueue CreateQueueWithChoiceFor(IPokemon pokemon)
{
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(pokemon);
return new BattleChoiceQueue([choice]);
}
/// <summary>
/// Helper to check whether a Pokémon received any SetStatus call. (Checked via ReceivedCalls, as arg
/// matchers cannot be used for <see cref="EventBatchId"/> parameters: its parameterless constructor
/// generates a random Guid, which breaks NSubstitute's argument specification binding.)
/// </summary>
private static bool ReceivedSetStatus(IPokemon pokemon) =>
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus");
/// <summary>
/// Bulbapedia: "Ice Fang deals damage and has a 10% chance of freezing the opponent."
/// When the freeze roll succeeds, the target is frozen by the user.
/// </summary>
[Test]
public void OnSecondaryEffect_FreezeRollSucceeds_TargetIsFrozen()
{
// Arrange
var (script, move, user, target, random, _) = CreateTestSetup(new BattleChoiceQueue([]));
random.EffectChance(10, move, target, 0).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.Received(1).SetStatus("frozen", user);
}
/// <summary>
/// Bulbapedia: "has a 10% chance of freezing the opponent."
/// The freeze roll is made with a 10 percent chance.
/// </summary>
[Test]
public void OnSecondaryEffect_FreezeRoll_UsesTenPercentChance()
{
// Arrange
var (script, move, _, target, random, _) = CreateTestSetup(new BattleChoiceQueue([]));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
random.Received(1).EffectChance(10, move, target, 0);
}
/// <summary>
/// Bulbapedia: "has a 10% chance of freezing the opponent."
/// When the freeze roll fails, the target is not frozen.
/// </summary>
[Test]
public async Task OnSecondaryEffect_FreezeRollFails_TargetIsNotFrozen()
{
// Arrange
var (script, move, _, target, random, _) = CreateTestSetup(new BattleChoiceQueue([]));
random.EffectChance(10, move, target, 0).Returns(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedSetStatus(target)).IsFalse();
}
/// <summary>
/// Bulbapedia: "It also has an independent 10% chance of causing the target to flinch, if the user
/// attacks before the target."
/// The target still has a queued choice this turn (so the user attacked first), and the flinch roll
/// succeeds: a <see cref="FlinchEffect"/> is added to the target.
/// </summary>
[Test]
public void OnSecondaryEffect_UserAttacksBeforeTargetAndFlinchRollSucceeds_TargetFlinches()
{
// Arrange
var (script, move, _, target, random, targetVolatile) = CreateTestSetup(null);
var queue = CreateQueueWithChoiceFor(target);
target.BattleData!.Battle.ChoiceQueue.Returns(queue);
random.EffectChance(10, move, target, 0).Returns(true, true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
targetVolatile.Received(1).Add(Arg.Any<FlinchEffect>());
}
/// <summary>
/// Bulbapedia: "It also has an independent 10% chance of causing the target to flinch".
/// The flinch chance is independent of the freeze chance: even when the freeze roll fails, a successful
/// flinch roll still causes the flinch.
/// </summary>
[Test]
public async Task OnSecondaryEffect_FreezeRollFailsButFlinchRollSucceeds_TargetStillFlinches()
{
// Arrange
var (script, move, _, target, random, targetVolatile) = CreateTestSetup(null);
var queue = CreateQueueWithChoiceFor(target);
target.BattleData!.Battle.ChoiceQueue.Returns(queue);
// First roll (freeze) fails, second roll (flinch) succeeds.
random.EffectChance(10, move, target, 0).Returns(false, true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedSetStatus(target)).IsFalse();
targetVolatile.Received(1).Add(Arg.Any<FlinchEffect>());
}
/// <summary>
/// Bulbapedia: the flinch can only happen "if the user attacks before the target."
/// The target has already moved this turn (no queued choice for it remains), so no flinch is applied
/// even though the rolls succeed.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetAlreadyMoved_TargetDoesNotFlinch()
{
// Arrange
var (script, move, _, target, random, targetVolatile) = CreateTestSetup(null);
// The queue only holds a choice for some other Pokémon; the target's choice already executed.
var queue = CreateQueueWithChoiceFor(Substitute.For<IPokemon>());
target.BattleData!.Battle.ChoiceQueue.Returns(queue);
random.EffectChance(10, move, target, 0).Returns(true, true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
targetVolatile.DidNotReceive().Add(Arg.Any<Script>());
}
/// <summary>
/// Technical test: when the battle has no active choice queue, the flinch check is skipped without
/// throwing, while the freeze effect can still apply.
/// </summary>
[Test]
public void OnSecondaryEffect_NoChoiceQueue_FreezeStillAppliesButNoFlinch()
{
// Arrange
var (script, move, user, target, random, targetVolatile) = CreateTestSetup(null);
random.EffectChance(10, move, target, 0).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.Received(1).SetStatus("frozen", user);
targetVolatile.DidNotReceive().Add(Arg.Any<Script>());
}
/// <summary>
/// Technical test: if the target has no <see cref="IPokemon.BattleData"/>, the script does nothing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHasNoBattleData_DoesNothing()
{
// Arrange
var script = new IceFang();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.BattleData.Returns((IPokemonBattleData?)null);
var targetVolatile = Substitute.For<IScriptSet>();
target.Volatile.Returns(targetVolatile);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedSetStatus(target)).IsFalse();
targetVolatile.DidNotReceive().Add(Arg.Any<Script>());
}
}

View File

@@ -0,0 +1,161 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
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="Imprison"/> move script and its <see cref="ImprisonEffect"/> volatile.
/// Gen VII Bulbapedia behavior: "As long as the user remains in battle, opponents cannot use any move which
/// is also known by the user. This includes opponents switched in after the move was used". Generation V
/// onwards: "Imprison still works even if no opponent has a move the user knows."
/// </summary>
public class ImprisonTests
{
/// <summary>
/// Creates a substitute learned move whose <see cref="IMoveData.Name"/> is the given name.
/// </summary>
private static ILearnedMove CreateLearnedMove(string name)
{
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new StringKey(name));
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
return learnedMove;
}
/// <summary>
/// Creates the Pokémon that used Imprison, knowing the given moves (with an empty move slot, as real
/// movesets can have them), and the <see cref="ImprisonEffect"/> bound to it.
/// </summary>
private static (ImprisonEffect effect, IPokemon user) CreateImprisonUser(params string[] knownMoves)
{
var user = Substitute.For<IPokemon>();
var moves = knownMoves.Select(ILearnedMove? (m) => CreateLearnedMove(m)).Append(null).ToArray();
user.Moves.Returns(moves);
return (new ImprisonEffect(user), user);
}
/// <summary>
/// Creates a move choice by the given Pokémon for a move with the given name.
/// </summary>
private static IMoveChoice CreateMoveChoice(IPokemon chooser, string moveName)
{
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(chooser);
var learnedMove = CreateLearnedMove(moveName);
choice.ChosenMove.Returns(learnedMove);
return choice;
}
/// <summary>
/// Bulbapedia: "As long as the user remains in battle, opponents cannot use any move which is also known
/// by the user." This includes opponents switched in after the move was used, so using the move puts the
/// <see cref="ImprisonEffect"/> on the battle's volatile scripts, where every move choice sees it.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Used_AddsImprisonEffectToBattleVolatile()
{
// Arrange
var script = new Imprison();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
var battle = Substitute.For<IBattle>();
battle.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
var battleVolatile = new ScriptSet(battle);
battle.Volatile.Returns(battleVolatile);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
target.BattleData.Returns(battleData);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(battleVolatile.Get<ImprisonEffect>()).IsNotNull();
}
/// <summary>
/// Technical test: without battle data on the target (outside of battle) the secondary effect does
/// nothing and does not throw.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetHasNoBattleData_DoesNotThrow()
{
// Arrange
var script = new Imprison();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.BattleData.Returns((IPokemonBattleData?)null);
// Act & Assert
script.OnSecondaryEffect(move, target, 0);
}
/// <summary>
/// Bulbapedia: "opponents cannot use any move which is also known by the user." An opponent choosing a
/// move the Imprison user knows has its selection prevented.
/// </summary>
[Test]
public async Task PreventMoveSelection_OpponentChoosesMoveKnownByUser_SelectionPrevented()
{
// Arrange
var (effect, _) = CreateImprisonUser("tackle", "imprison");
var opponent = Substitute.For<IPokemon>();
var choice = CreateMoveChoice(opponent, "tackle");
var prevent = false;
// Act
effect.PreventMoveSelection(choice, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: only moves "also known by the user" are sealed. An opponent choosing a move the Imprison
/// user does not know is free to use it.
/// </summary>
[Test]
public async Task PreventMoveSelection_OpponentChoosesMoveNotKnownByUser_SelectionAllowed()
{
// Arrange
var (effect, _) = CreateImprisonUser("tackle", "imprison");
var opponent = Substitute.For<IPokemon>();
var choice = CreateMoveChoice(opponent, "ember");
var prevent = false;
// Act
effect.PreventMoveSelection(choice, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia: "opponents cannot use any move which is also known by the user" — the user itself is not
/// restricted and can keep using its own moves.
/// </summary>
[Test]
public async Task PreventMoveSelection_UsersOwnChoice_SelectionAllowed()
{
// Arrange
var (effect, user) = CreateImprisonUser("tackle", "imprison");
var choice = CreateMoveChoice(user, "tackle");
var prevent = false;
// Act
effect.PreventMoveSelection(choice, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
}

View File

@@ -0,0 +1,158 @@
using PkmnLib.Dynamic.Events;
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Incinerate"/> move script.
/// Gen VII Bulbapedia behavior: "Incinerate inflicts damage to all adjacent opponents. The move destroys any
/// Berries held by hit Pokémon". Generation VI onwards: "The move now also destroys Gems."
/// </summary>
public class IncinerateTests
{
/// <summary>
/// Creates a fully mocked test setup for Incinerate tests, with the target holding the given item
/// (nothing when null).
/// </summary>
private static (Incinerate script, IExecutingMove move, IPokemon target, IHitData hitData, EventHook eventHook)
CreateTestSetup(IItem? heldItem)
{
var script = new Incinerate();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
var eventHook = new EventHook();
var battle = Substitute.For<IBattle>();
battle.EventHook.Returns(eventHook);
move.Battle.Returns(battle);
target.HeldItem.Returns(heldItem);
if (heldItem != null)
{
target.TryStealHeldItem(out Arg.Any<IItem?>()).Returns(x =>
{
x[0] = heldItem;
return true;
});
}
return (script, move, target, hitData, eventHook);
}
/// <summary>
/// Creates a substitute held item with the given name and category.
/// </summary>
private static IItem CreateItem(string name, ItemCategory category)
{
var item = Substitute.For<IItem>();
item.Name.Returns(new StringKey(name));
item.Category.Returns(category);
return item;
}
/// <summary>
/// Helper to check whether the target's held item was taken away.
/// </summary>
private static bool ItemWasRemoved(IPokemon pokemon) =>
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "TryStealHeldItem");
/// <summary>
/// Bulbapedia: "The move destroys any Berries held by hit Pokémon." A held Berry is removed from the
/// target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHoldsBerry_BerryIsRemoved()
{
// Arrange
var berry = CreateItem("oran_berry", ItemCategory.Berry);
var (script, move, target, _, _) = CreateTestSetup(berry);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ItemWasRemoved(target)).IsTrue();
}
/// <summary>
/// Bulbapedia: "The move destroys any Berries held by hit Pokémon" — the destruction is announced
/// through a <see cref="DialogEvent"/> so the battle log can show it.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHoldsBerry_FiresItemIncineratedDialogEvent()
{
// Arrange
var berry = CreateItem("oran_berry", ItemCategory.Berry);
var (script, move, target, _, eventHook) = CreateTestSetup(berry);
DialogEvent? captured = null;
eventHook.Handler += (_, args) =>
{
if (args is DialogEvent dialogEvent)
captured = dialogEvent;
};
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(captured).IsNotNull();
await Assert.That(captured!.Message).IsEqualTo("item_incinerated");
}
/// <summary>
/// Bulbapedia: only Berries (and, from Gen VI, Gems) are destroyed. A regular held item is left alone.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHoldsRegularItem_ItemIsNotRemoved()
{
// Arrange
var item = CreateItem("leftovers", ItemCategory.MiscItem);
var (script, move, target, _, _) = CreateTestSetup(item);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ItemWasRemoved(target)).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "The move now also destroys Gems." A held Gem is removed from
/// the target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHoldsGem_GemIsRemoved()
{
// Arrange
var gem = CreateItem("normal_gem", ItemCategory.MiscItem);
var (script, move, target, _, _) = CreateTestSetup(gem);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ItemWasRemoved(target)).IsTrue();
}
/// <summary>
/// Bulbapedia: "Incinerate inflicts damage to all adjacent opponents." Destroying a Berry is a bonus
/// effect: against a target holding nothing the move still simply deals its damage, it does not fail.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetHoldsNoItem_HitIsNotMarkedAsFailed()
{
// Arrange
var (script, move, target, hitData, _) = CreateTestSetup(null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.DidNotReceive().Fail();
}
}

View File

@@ -0,0 +1,52 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="IncreasedCriticalStage"/> script, the generic effect used by high-critical-ratio
/// moves such as Slash. Behavior is verified against the Bulbapedia page for Slash: "Slash deals damage and
/// has an increased critical hit ratio."
/// </summary>
public class IncreasedCriticalStageTests
{
/// <summary>
/// Bulbapedia: Slash "has an increased critical hit ratio" — the move raises the critical hit stage of
/// each hit by one.
/// </summary>
[Test, Arguments((byte)0, (byte)1), Arguments((byte)1, (byte)2), Arguments((byte)2, (byte)3)]
public async Task ChangeCriticalStage_AnyStage_IncreasesStageByOne(byte initialStage, byte expectedStage)
{
// Arrange
var script = new IncreasedCriticalStage();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var stage = initialStage;
// Act
script.ChangeCriticalStage(move, target, 0, ref stage);
// Assert
await Assert.That(stage).IsEqualTo(expectedStage);
}
/// <summary>
/// Technical test: a critical stage that is already at the maximum representable value is left unchanged
/// instead of overflowing back to zero.
/// </summary>
[Test]
public async Task ChangeCriticalStage_StageAtByteMax_StageUnchanged()
{
// Arrange
var script = new IncreasedCriticalStage();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var stage = byte.MaxValue;
// Act
script.ChangeCriticalStage(move, target, 0, ref stage);
// Assert
await Assert.That(stage).IsEqualTo(byte.MaxValue);
}
}

View File

@@ -0,0 +1,313 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
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.Pokemon;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Infestation"/> move script and its <see cref="InfestationEffect"/> volatile.
/// Gen VII Bulbapedia behavior: "Infestation inflicts 1/8 of the target's maximum HP as damage per turn for
/// four to five turns upon use, in addition to the damage dealt when it is used. It also traps the target,
/// preventing switching and escape." "Grip Claw extends duration to 7 turns" and "Binding Band increases
/// per-turn damage from 1/8 to 1/6 of maximum HP."
/// </summary>
public class InfestationTests
{
/// <summary>
/// Test helper script that modifies the Infestation duration/damage through the
/// <see cref="CustomTriggers.ModifyBind"/> custom trigger, the same way the Grip Claw and Binding Band
/// item scripts do.
/// </summary>
[Script(ScriptCategory.Pokemon, "test_modify_infestation_trigger")]
private class ModifyBindTrigger : Script, IScriptCustomTrigger
{
private readonly int? _duration;
private readonly float? _damagePercent;
public ModifyBindTrigger(int? duration = null, float? damagePercent = null)
{
_duration = duration;
_damagePercent = damagePercent;
}
public void CustomTrigger(StringKey eventName, ICustomTriggerArgs args)
{
if (eventName != CustomTriggers.ModifyBind || args is not CustomTriggers.ModifyBindArgs bindArgs)
return;
if (_duration.HasValue)
bindArgs.Duration = _duration.Value;
if (_damagePercent.HasValue)
bindArgs.DamagePercent = _damagePercent.Value;
}
}
/// <summary>
/// Creates a fully mocked test setup for Infestation tests. The target gets a real
/// <see cref="ScriptSet"/> as its volatile set, so the applied effect can be inspected and ticked.
/// Scripts passed as <paramref name="userScripts"/> are attached to the user, so the
/// <see cref="CustomTriggers.ModifyBind"/> trigger pass runs over them (the Grip Claw / Binding Band
/// stand-in).
/// </summary>
private static (Infestation script, IExecutingMove move, IPokemon user, IPokemon target, ScriptSet targetVolatile,
IBattleRandom random, IHitData hitData) CreateTestSetup(uint targetMaxHp = 80, params Script[] userScripts)
{
var script = new Infestation();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
var containers = userScripts.Select(IEnumerable<ScriptContainer> (s) => new ScriptContainer(s)).ToArray();
user.GetScripts().Returns(_ => new ScriptIterator(containers));
move.User.Returns(user);
var random = Substitute.For<IBattleRandom>();
var battle = Substitute.For<IBattle>();
battle.Random.Returns(random);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
user.BattleData.Returns(battleData);
var target = Substitute.For<IPokemon>();
target.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var targetVolatile = new ScriptSet(target);
target.Volatile.Returns(targetVolatile);
target.BoostedStats.Returns(new StatisticSet<uint>(targetMaxHp, 1, 1, 1, 1, 1));
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
return (script, move, user, target, targetVolatile, random, hitData);
}
/// <summary>
/// Helper to extract the damage amount from a Pokémon's received Damage calls.
/// </summary>
private static uint? GetDamageAmount(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Bulbapedia: "It also traps the target" — the move script applies the <see cref="InfestationEffect"/>
/// volatile, which implements the trap, to the target that was hit.
/// </summary>
[Test]
public async Task OnSecondaryEffect_MoveHits_AddsInfestationEffectToTarget()
{
// Arrange
var (script, move, _, target, targetVolatile, _, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Get<InfestationEffect>()).IsNotNull();
}
/// <summary>
/// A target that is already afflicted by an infestation cannot be infested again: the hit fails and no
/// second effect is added.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetAlreadyInfested_HitFails()
{
// Arrange
var (script, move, _, target, targetVolatile, _, hitData) = CreateTestSetup();
targetVolatile.Add(new InfestationEffect(target, 4, 1f / 8f));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(targetVolatile.Count).IsEqualTo(1);
}
/// <summary>
/// Bulbapedia: the trap lasts "for four to five turns". With the low random roll the trap ticks four
/// times and is gone afterwards.
/// </summary>
[Test]
public async Task OnSecondaryEffect_LowDurationRoll_TrapLastsFourTurns()
{
// Arrange
var (script, move, _, target, targetVolatile, random, _) = CreateTestSetup();
random.GetBool().Returns(false);
var battle = Substitute.For<IBattle>();
// Act
script.OnSecondaryEffect(move, target, 0);
var effect = targetVolatile.Get<InfestationEffect>()!;
for (var i = 0; i < 3; i++)
effect.OnEndTurn(target, battle);
var presentAfterThreeTurns = targetVolatile.Contains(new StringKey("infestation"));
effect.OnEndTurn(target, battle);
// Assert
await Assert.That(presentAfterThreeTurns).IsTrue();
await Assert.That(targetVolatile.Contains(new StringKey("infestation"))).IsFalse();
}
/// <summary>
/// Bulbapedia: the trap lasts "for four to five turns". With the high random roll the trap ticks five
/// times and is gone afterwards.
/// </summary>
[Test]
public async Task OnSecondaryEffect_HighDurationRoll_TrapLastsFiveTurns()
{
// Arrange
var (script, move, _, target, targetVolatile, random, _) = CreateTestSetup();
random.GetBool().Returns(true);
var battle = Substitute.For<IBattle>();
// Act
script.OnSecondaryEffect(move, target, 0);
var effect = targetVolatile.Get<InfestationEffect>()!;
for (var i = 0; i < 4; i++)
effect.OnEndTurn(target, battle);
var presentAfterFourTurns = targetVolatile.Contains(new StringKey("infestation"));
effect.OnEndTurn(target, battle);
// Assert
await Assert.That(presentAfterFourTurns).IsTrue();
await Assert.That(targetVolatile.Contains(new StringKey("infestation"))).IsFalse();
}
/// <summary>
/// Bulbapedia: "Infestation inflicts 1/8 of the target's maximum HP as damage per turn".
/// </summary>
[Test, Arguments(96u, 12u), Arguments(100u, 12u), Arguments(8u, 1u)]
public async Task OnEndTurn_TrappedPokemon_TakesOneEighthOfMaxHpAsDamage(uint maxHealth, uint expectedDamage)
{
// Arrange
var owner = Substitute.For<IPokemon>();
owner.BoostedStats.Returns(new StatisticSet<uint>(maxHealth, 1, 1, 1, 1, 1));
var effect = new InfestationEffect(owner, 4, 1f / 8f);
// Act
effect.OnEndTurn(owner, Substitute.For<IBattle>());
// Assert
await Assert.That(GetDamageAmount(owner)!.Value).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: the per-turn damage is residual trap damage, not move damage, so it uses
/// <see cref="DamageSource.Misc"/>.
/// </summary>
[Test]
public async Task OnEndTurn_TrappedPokemon_DamageIsIndirect()
{
// Arrange
var owner = Substitute.For<IPokemon>();
owner.BoostedStats.Returns(new StatisticSet<uint>(80, 1, 1, 1, 1, 1));
var effect = new InfestationEffect(owner, 4, 1f / 8f);
// Act
effect.OnEndTurn(owner, Substitute.For<IBattle>());
// Assert
var call = owner.ReceivedCalls().First(c => c.GetMethodInfo().Name == "Damage");
await Assert.That((DamageSource)call.GetArguments()[1]!).IsEqualTo(DamageSource.Misc);
}
/// <summary>
/// Bulbapedia: Infestation traps the target, "preventing switching".
/// </summary>
[Test]
public async Task PreventSelfSwitch_TrappedPokemon_CannotSwitchOut()
{
// Arrange
var effect = new InfestationEffect(Substitute.For<IPokemon>(), 4, 1f / 8f);
var prevent = false;
// Act
effect.PreventSelfSwitch(Substitute.For<ISwitchChoice>(), ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: Infestation traps the target, preventing "escape".
/// </summary>
[Test]
public async Task PreventSelfRunAway_TrappedPokemon_CannotFlee()
{
// Arrange
var effect = new InfestationEffect(Substitute.For<IPokemon>(), 4, 1f / 8f);
var prevent = false;
// Act
effect.PreventSelfRunAway(Substitute.For<IFleeChoice>(), ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "Binding Band increases per-turn damage from 1/8 to 1/6 of maximum HP."
/// The damage change flows through the <see cref="CustomTriggers.ModifyBind"/> custom trigger; a script
/// on the user that sets the damage to 1/6 results in end-of-turn damage of 1/6 max HP.
/// </summary>
[Test]
public async Task OnEndTurn_DamageSetToOneSixthByTrigger_DamageIsOneSixthOfMaxHp()
{
// Arrange
var (script, move, _, target, targetVolatile, _, _) =
CreateTestSetup(120, new ModifyBindTrigger(damagePercent: 1f / 6f));
// Act
script.OnSecondaryEffect(move, target, 0);
var effect = targetVolatile.Get<InfestationEffect>()!;
effect.OnEndTurn(target, Substitute.For<IBattle>());
// Assert - 1/6 of 120 max HP
await Assert.That(GetDamageAmount(target)!.Value).IsEqualTo(20u);
}
/// <summary>
/// Bulbapedia: "Grip Claw extends duration to 7 turns." The duration change flows through the
/// <see cref="CustomTriggers.ModifyBind"/> custom trigger; a script on the user that sets the duration
/// to 7 results in a trap that is still active after six end-of-turn ticks.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DurationSetToSevenByTrigger_TrapLastsSevenTurns()
{
// Arrange
var (script, move, _, target, targetVolatile, random, _) = CreateTestSetup(80, new ModifyBindTrigger(7));
random.GetBool().Returns(false);
var battle = Substitute.For<IBattle>();
// Act
script.OnSecondaryEffect(move, target, 0);
var effect = targetVolatile.Get<InfestationEffect>()!;
for (var i = 0; i < 6; i++)
effect.OnEndTurn(target, battle);
// Assert
await Assert.That(targetVolatile.Contains(new StringKey("infestation"))).IsTrue();
}
/// <summary>
/// Technical test: if the user has no <see cref="IPokemon.BattleData"/>, no effect is applied.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserHasNoBattleData_NoEffectAdded()
{
// Arrange
var (script, move, user, target, targetVolatile, _, _) = CreateTestSetup();
user.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Count).IsEqualTo(0);
}
}

View File

@@ -0,0 +1,237 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static;
using PkmnLib.Static.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Ingrain"/> move script and its <see cref="IngrainEffect"/> volatile.
/// Gen VII Bulbapedia behavior: the user plants roots, restoring 1/16th of its maximum HP each turn while
/// becoming unable to switch out; forced-switch moves like Roar fail against it. From Generation IV onwards
/// the move grounds the affected Pokémon, making them susceptible to Ground moves even if Flying-type.
/// Generation VI onwards: "Ghost-type Pokémon under the effects of Ingrain are now able to switch out."
/// </summary>
public class IngrainTests
{
/// <summary>
/// Creates a rooted Pokémon substitute with the given maximum HP, together with the
/// <see cref="IngrainEffect"/> that roots it.
/// </summary>
private static (IngrainEffect effect, IPokemon owner) CreateRootedPokemon(uint maxHealth = 100)
{
var owner = Substitute.For<IPokemon>();
owner.BoostedStats.Returns(new StatisticSet<uint>(maxHealth, 1, 1, 1, 1, 1));
return (new IngrainEffect(owner), owner);
}
/// <summary>
/// Helper to extract the heal amount from a Pokémon's received Heal calls.
/// </summary>
private static uint? GetHealAmount(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Heal");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Bulbapedia: "The user plants roots" — using the move attaches the <see cref="IngrainEffect"/>
/// volatile to the user.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Used_AddsIngrainEffectToUser()
{
// Arrange
var script = new Ingrain();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var userVolatile = new ScriptSet(user);
user.Volatile.Returns(userVolatile);
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(userVolatile.Get<IngrainEffect>()).IsNotNull();
}
/// <summary>
/// Bulbapedia: the roots restore "1/16th maximum HP each turn".
/// </summary>
[Test, Arguments(160u, 10u), Arguments(100u, 6u), Arguments(17u, 1u)]
public async Task OnEndTurn_RootedPokemon_HealsOneSixteenthOfMaxHp(uint maxHealth, uint expectedHeal)
{
// Arrange
var (effect, owner) = CreateRootedPokemon(maxHealth);
// Act
effect.OnEndTurn(owner, Substitute.For<IBattle>());
// Assert
await Assert.That(GetHealAmount(owner)!.Value).IsEqualTo(expectedHeal);
}
/// <summary>
/// Bulbapedia: the rooted user becomes "unable to switch out".
/// </summary>
[Test]
public async Task PreventSelfSwitch_RootedPokemon_CannotSwitchOut()
{
// Arrange
var (effect, _) = CreateRootedPokemon();
var prevent = false;
// Act
effect.PreventSelfSwitch(Substitute.For<ISwitchChoice>(), ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: the user is rooted in place; a rooted wild Pokémon cannot flee the battle.
/// </summary>
[Test]
public async Task PreventSelfRunAway_RootedPokemon_CannotFlee()
{
// Arrange
var (effect, _) = CreateRootedPokemon();
var prevent = false;
// Act
effect.PreventSelfRunAway(Substitute.For<IFleeChoice>(), ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: the rooted user cannot be forced out by "forced-switch moves like Roar" — Roar and
/// Whirlwind fail against it.
/// </summary>
[Test, Arguments("roar"), Arguments("whirlwind")]
public async Task FailIncomingMove_ForcedSwitchMove_MoveFails(string moveName)
{
// Arrange
var (effect, owner) = CreateRootedPokemon();
var incomingMove = Substitute.For<IExecutingMove>();
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new Static.Utils.StringKey(moveName));
incomingMove.UseMove.Returns(moveData);
var fail = false;
// Act
effect.FailIncomingMove(incomingMove, owner, ref fail);
// Assert
await Assert.That(fail).IsTrue();
}
/// <summary>
/// Bulbapedia: only forced-switch moves are blocked by the roots; any other incoming move works
/// normally.
/// </summary>
[Test]
public async Task FailIncomingMove_RegularMove_MoveDoesNotFail()
{
// Arrange
var (effect, owner) = CreateRootedPokemon();
var incomingMove = Substitute.For<IExecutingMove>();
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new Static.Utils.StringKey("tackle"));
incomingMove.UseMove.Returns(moveData);
var fail = false;
// Act
effect.FailIncomingMove(incomingMove, owner, ref fail);
// Assert
await Assert.That(fail).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation IV onwards): the move grounds the affected Pokémon, making them susceptible to
/// Ground moves even if Flying-type. For an incoming Ground-type move, the rooted Pokémon's
/// Ground-immune types are ignored.
/// </summary>
[Test]
public async Task ChangeTypesForIncomingMove_GroundMoveAgainstFlyingType_RemovesGroundImmuneType()
{
// Arrange
var (effect, owner) = CreateRootedPokemon();
var library = LibraryHelpers.LoadLibrary();
owner.Library.Returns(library);
var types = library.StaticLibrary.Types;
await Assert.That(types.TryGetTypeIdentifier("ground", out var ground)).IsTrue();
await Assert.That(types.TryGetTypeIdentifier("flying", out var flying)).IsTrue();
await Assert.That(types.TryGetTypeIdentifier("grass", out var grass)).IsTrue();
var incomingMove = Substitute.For<IExecutingMove>();
var moveData = Substitute.For<IMoveData>();
moveData.MoveType.Returns(ground);
incomingMove.UseMove.Returns(moveData);
var targetTypes = new List<TypeIdentifier> { flying, grass };
// Act
effect.ChangeTypesForIncomingMove(incomingMove, owner, 0, targetTypes);
// Assert
await Assert.That(targetTypes.Contains(flying)).IsFalse();
await Assert.That(targetTypes.Contains(grass)).IsTrue();
}
/// <summary>
/// The grounding only matters for Ground-type moves: any other incoming move sees the rooted Pokémon's
/// types unchanged.
/// </summary>
[Test]
public async Task ChangeTypesForIncomingMove_NonGroundMove_TypesUnchanged()
{
// Arrange
var (effect, owner) = CreateRootedPokemon();
var library = LibraryHelpers.LoadLibrary();
owner.Library.Returns(library);
var types = library.StaticLibrary.Types;
await Assert.That(types.TryGetTypeIdentifier("water", out var water)).IsTrue();
await Assert.That(types.TryGetTypeIdentifier("flying", out var flying)).IsTrue();
var incomingMove = Substitute.For<IExecutingMove>();
var moveData = Substitute.For<IMoveData>();
moveData.MoveType.Returns(water);
incomingMove.UseMove.Returns(moveData);
var targetTypes = new List<TypeIdentifier> { flying };
// Act
effect.ChangeTypesForIncomingMove(incomingMove, owner, 0, targetTypes);
// Assert
await Assert.That(targetTypes.Contains(flying)).IsTrue();
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "Ghost-type Pokémon under the effects of Ingrain are now able to
/// switch out."
/// </summary>
[Test]
public async Task PreventSelfSwitch_GhostTypeRooted_CanStillSwitchOut()
{
// Arrange
var (effect, owner) = CreateRootedPokemon();
owner.Types.Returns(new List<TypeIdentifier> { new(8, "ghost") });
var prevent = false;
var choice = Substitute.For<ISwitchChoice>();
choice.User.Returns(owner);
// Act
effect.PreventSelfSwitch(choice, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
}

View File

@@ -0,0 +1,169 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
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="Instruct"/> move script.
/// Gen VII Bulbapedia behavior: "Instruct causes the target to immediately repeat its most recent move". It
/// fails against targets with no prior moves, and "cannot force repetition of Instruct itself, Sketch,
/// Transform, Mimic, King's Shield, Struggle, moves requiring recharge (like Hyper Beam), moves with charging
/// turns (like Dig), moves calling other moves (like Metronome), Z-Moves, or Max Moves."
/// </summary>
public class InstructTests
{
/// <summary>
/// Creates a fully mocked test setup for Instruct tests. When <paramref name="lastMoveName"/> is given,
/// the target has a repeatable last move choice of that name; otherwise it has not moved yet.
/// </summary>
private static (Instruct script, IExecutingMove move, IBattle battle, IPokemon target, IHitData hitData, IMoveChoice
? lastChoice) CreateTestSetup(string? lastMoveName)
{
var script = new Instruct();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var battle = Substitute.For<IBattle>();
var userBattleData = Substitute.For<IPokemonBattleData>();
userBattleData.Battle.Returns(battle);
user.BattleData.Returns(userBattleData);
var target = Substitute.For<IPokemon>();
target.IsUsable.Returns(true);
var targetBattleData = Substitute.For<IPokemonBattleData>();
target.BattleData.Returns(targetBattleData);
IMoveChoice? lastChoice = null;
if (lastMoveName != null)
{
lastChoice = Substitute.For<IMoveChoice>();
// The choice needs a real (empty) script iterator so the turn runner's hook pass works on it.
lastChoice.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
lastChoice.User.Returns(Substitute.For<IPokemon>());
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new StringKey(lastMoveName));
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
lastChoice.ChosenMove.Returns(learnedMove);
}
targetBattleData.LastMoveChoice.Returns(lastChoice);
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
return (script, move, battle, target, hitData, lastChoice);
}
/// <summary>
/// Bulbapedia: Instruct "fails against targets with no prior moves." A target that has not used a move
/// yet has no choice to repeat, so the hit fails.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetHasNotMovedYet_HitFails()
{
// Arrange
var (script, move, _, target, hitData, _) = CreateTestSetup(null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
}
/// <summary>
/// Bulbapedia: the repeated move must actually be usable (e.g. it fails if the move has no PP left).
/// When the battle reports the last choice as unusable, the hit fails.
/// </summary>
[Test]
public void OnSecondaryEffect_LastChoiceCannotBeUsed_HitFails()
{
// Arrange
var (script, move, battle, target, hitData, lastChoice) = CreateTestSetup("tackle");
battle.CanUse(lastChoice!).Returns(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
}
/// <summary>
/// Bulbapedia: "Instruct causes the target to immediately repeat its most recent move". With a usable
/// last move choice the move does not fail (the choice is handed to the turn runner for execution).
/// </summary>
[Test]
public void OnSecondaryEffect_LastChoiceUsable_HitDoesNotFail()
{
// Arrange
var (script, move, battle, target, hitData, lastChoice) = CreateTestSetup("tackle");
battle.CanUse(lastChoice!).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Technical test: an unusable (e.g. fainted) target is skipped entirely — nothing is executed and the
/// hit is not failed.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetNotUsable_DoesNothing()
{
// Arrange
var (script, move, _, target, hitData, _) = CreateTestSetup("tackle");
target.IsUsable.Returns(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Technical test: if the user has no <see cref="IPokemon.BattleData"/>, the script does nothing.
/// </summary>
[Test]
public void OnSecondaryEffect_UserHasNoBattleData_DoesNothing()
{
// Arrange
var (script, move, _, target, hitData, _) = CreateTestSetup("tackle");
move.User.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: "The move cannot force repetition of Instruct itself, Sketch, Transform, Mimic, King's
/// Shield, Struggle, moves requiring recharge (like Hyper Beam), moves with charging turns (like Dig),
/// moves calling other moves (like Metronome), Z-Moves, or Max Moves." When the target's last move is
/// such a move, Instruct fails.
/// </summary>
[Test]
public void OnSecondaryEffect_LastMoveIsInstructItself_HitFails()
{
// Arrange
var (script, move, battle, target, hitData, lastChoice) = CreateTestSetup("instruct");
battle.CanUse(lastChoice!).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
}
}

View File

@@ -0,0 +1,159 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Battle;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="IonDeluge"/> move script and its <see cref="IonDelugeEffect"/> battle volatile.
/// Gen VII Bulbapedia behavior (Generation VI base text): "For the remainder of the current turn, Ion Deluge
/// causes all Normal-type moves to become Electric-type instead, including status moves." (The Gen VII
/// section only describes a version 1.0 glitch of Ultra Sun/Ultra Moon, which is not intended behavior.)
/// </summary>
public class IonDelugeTests
{
/// <summary>
/// Bulbapedia: "Ion Deluge causes all Normal-type moves to become Electric-type" — the effect applies to
/// the whole field, so using the move adds the <see cref="IonDelugeEffect"/> to the battle's volatile
/// scripts.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Used_AddsIonDelugeEffectToBattle()
{
// Arrange
var script = new IonDeluge();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var battle = Substitute.For<IBattle>();
battle.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var battleVolatile = new ScriptSet(battle);
battle.Volatile.Returns(battleVolatile);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
user.BattleData.Returns(battleData);
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(battleVolatile.Get<IonDelugeEffect>()).IsNotNull();
}
/// <summary>
/// Technical test: if the user has no <see cref="IPokemon.BattleData"/>, the script does nothing and
/// does not throw.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserHasNoBattleData_DoesNothing()
{
// Arrange
var script = new IonDeluge();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
move.User.Returns(user);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert - nothing thrown, nothing to observe
await Assert.That(user.BattleData).IsNull();
}
/// <summary>
/// Bulbapedia: "Ion Deluge causes all Normal-type moves to become Electric-type instead, including
/// status moves."
/// </summary>
[Test]
public async Task ChangeMoveType_NormalTypeMove_BecomesElectric()
{
// Arrange
var effect = new IonDelugeEffect();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var library = LibraryHelpers.LoadLibrary();
target.Library.Returns(library);
var types = library.StaticLibrary.Types;
await Assert.That(types.TryGetTypeIdentifier("normal", out var normal)).IsTrue();
await Assert.That(types.TryGetTypeIdentifier("electric", out var electric)).IsTrue();
TypeIdentifier? moveType = normal;
// Act
effect.ChangeMoveType(move, target, 0, ref moveType);
// Assert
await Assert.That(moveType.HasValue).IsTrue();
await Assert.That(moveType!.Value).IsEqualTo(electric);
}
/// <summary>
/// Bulbapedia: only "Normal-type moves" are converted; a move of any other type keeps its type.
/// </summary>
[Test]
public async Task ChangeMoveType_NonNormalTypeMove_TypeUnchanged()
{
// Arrange
var effect = new IonDelugeEffect();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var library = LibraryHelpers.LoadLibrary();
target.Library.Returns(library);
var types = library.StaticLibrary.Types;
await Assert.That(types.TryGetTypeIdentifier("water", out var water)).IsTrue();
TypeIdentifier? moveType = water;
// Act
effect.ChangeMoveType(move, target, 0, ref moveType);
// Assert
await Assert.That(moveType.HasValue).IsTrue();
await Assert.That(moveType!.Value).IsEqualTo(water);
}
/// <summary>
/// Technical test: a move without a resolved type is left alone.
/// </summary>
[Test]
public async Task ChangeMoveType_NullMoveType_TypeUnchanged()
{
// Arrange
var effect = new IonDelugeEffect();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
TypeIdentifier? moveType = null;
// Act
effect.ChangeMoveType(move, target, 0, ref moveType);
// Assert
await Assert.That(moveType).IsNull();
}
/// <summary>
/// Bulbapedia: the type conversion lasts "for the remainder of the current turn" — at the end of the
/// turn the effect must remove itself from the battle.
/// </summary>
[Test]
public async Task OnEndTurn_TurnEnds_EffectRemovesItself()
{
// Arrange
var effect = new IonDelugeEffect();
var battle = Substitute.For<IBattle>();
battle.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var battleVolatile = new ScriptSet(battle);
battle.Volatile.Returns(battleVolatile);
battleVolatile.Add(effect);
// Act - run the end-of-turn hook, if the effect has one at all
if (effect is IScriptOnEndTurn onEndTurn)
onEndTurn.OnEndTurn(battle, battle);
// Assert
await Assert.That(battleVolatile.Contains(ScriptUtils.ResolveName<IonDelugeEffect>())).IsFalse();
}
}

View File

@@ -0,0 +1,189 @@
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Battle;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Libraries;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Judgement"/> move.
/// </summary>
public class JudgementTests
{
public record TestCaseData(string? ItemName, string ExpectedTypeName)
{
public override string ToString() => $"Judgement {ItemName ?? "None"} => {ExpectedTypeName}";
}
public static IEnumerable<Func<TestCaseData>> ChangeMoveTypeData()
{
yield return () => new TestCaseData("draco_plate", "dragon");
yield return () => new TestCaseData("dread_plate", "dark");
yield return () => new TestCaseData("earth_plate", "ground");
yield return () => new TestCaseData("fist_plate", "fighting");
yield return () => new TestCaseData("flame_plate", "fire");
yield return () => new TestCaseData("icicle_plate", "ice");
yield return () => new TestCaseData("insect_plate", "bug");
yield return () => new TestCaseData("iron_plate", "steel");
yield return () => new TestCaseData("meadow_plate", "grass");
yield return () => new TestCaseData("mind_plate", "psychic");
yield return () => new TestCaseData("pixie_plate", "fairy");
yield return () => new TestCaseData("sky_plate", "flying");
yield return () => new TestCaseData("splash_plate", "water");
yield return () => new TestCaseData("spooky_plate", "ghost");
yield return () => new TestCaseData("stone_plate", "rock");
yield return () => new TestCaseData("toxic_plate", "poison");
yield return () => new TestCaseData("zap_plate", "electric");
}
private static TypeLibrary CreateTypeLibrary()
{
var typeLibrary = new TypeLibrary();
typeLibrary.RegisterType("normal");
typeLibrary.RegisterType("fighting");
typeLibrary.RegisterType("flying");
typeLibrary.RegisterType("poison");
typeLibrary.RegisterType("ground");
typeLibrary.RegisterType("rock");
typeLibrary.RegisterType("bug");
typeLibrary.RegisterType("ghost");
typeLibrary.RegisterType("steel");
typeLibrary.RegisterType("fire");
typeLibrary.RegisterType("water");
typeLibrary.RegisterType("grass");
typeLibrary.RegisterType("electric");
typeLibrary.RegisterType("psychic");
typeLibrary.RegisterType("ice");
typeLibrary.RegisterType("dragon");
typeLibrary.RegisterType("dark");
typeLibrary.RegisterType("fairy");
return typeLibrary;
}
private static (Judgement Script, IExecutingMove Move, IPokemon Target) CreateTestSetup(string? itemName)
{
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var user = Substitute.For<IPokemon>();
var dynamicLibrary = Substitute.For<IDynamicLibrary>();
var staticLibrary = Substitute.For<IStaticLibrary>();
target.Library.Returns(dynamicLibrary);
dynamicLibrary.StaticLibrary.Returns(staticLibrary);
staticLibrary.Types.Returns(CreateTypeLibrary());
move.User.Returns(user);
if (itemName != null)
{
var item = Substitute.For<IItem>();
item.Name.Returns(new StringKey(itemName));
user.HeldItem.Returns(item);
}
else
{
user.HeldItem.Returns((IItem?)null);
}
return (new Judgement(), move, target);
}
/// <summary>
/// Bulbapedia: "The type of Judgment depends on the type of Plate held by the user".
/// </summary>
[Test, MethodDataSource(nameof(ChangeMoveTypeData))]
public async Task ChangeMoveType_UserHoldingPlate_ChangesMoveType(TestCaseData test)
{
// Arrange
var (script, move, target) = CreateTestSetup(test.ItemName);
TypeIdentifier? typeIdentifier = new TypeIdentifier(1, "normal");
// Act
script.ChangeMoveType(move, target, 0, ref typeIdentifier);
// Assert
await Assert.That(typeIdentifier!.Value.Name.ToString()).IsEqualTo(test.ExpectedTypeName);
}
/// <summary>
/// Bulbapedia: "being Normal-type if there is no held Plate". Without a held item the move type
/// is left unchanged.
/// </summary>
[Test]
public async Task ChangeMoveType_UserNotHoldingItem_MoveTypeUnchanged()
{
// Arrange
var (script, move, target) = CreateTestSetup(null);
TypeIdentifier? typeIdentifier = new TypeIdentifier(1, "normal");
// Act
script.ChangeMoveType(move, target, 0, ref typeIdentifier);
// Assert
await Assert.That(typeIdentifier!.Value.Name.ToString()).IsEqualTo("normal");
}
/// <summary>
/// Bulbapedia: "being Normal-type if there is no held Plate". A held item that is not a Plate
/// leaves the move type unchanged.
/// </summary>
[Test]
public async Task ChangeMoveType_UserHoldingNonPlateItem_MoveTypeUnchanged()
{
// Arrange
var (script, move, target) = CreateTestSetup("oran_berry");
TypeIdentifier? typeIdentifier = new TypeIdentifier(1, "normal");
// Act
script.ChangeMoveType(move, target, 0, ref typeIdentifier);
// Assert
await Assert.That(typeIdentifier!.Value.Name.ToString()).IsEqualTo("normal");
}
/// <summary>
/// Bulbapedia: "Judgment's type is unaffected by Z-Crystals held by the user, remaining a
/// Normal-type move."
/// </summary>
[Test]
public async Task ChangeMoveType_UserHoldingZCrystal_MoveTypeRemainsNormal()
{
// Arrange
var (script, move, target) = CreateTestSetup("firium_z");
TypeIdentifier? typeIdentifier = new TypeIdentifier(1, "normal");
// Act
script.ChangeMoveType(move, target, 0, ref typeIdentifier);
// Assert
await Assert.That(typeIdentifier!.Value.Name.ToString()).IsEqualTo("normal");
}
/// <summary>
/// Bulbapedia: "If Magic Room is in effect, Judgment's type will always be Normal regardless of
/// the Plate held." With <see cref="MagicRoomEffect"/> on the battle, a held Plate must not
/// change the move type.
/// </summary>
[Test]
public async Task ChangeMoveType_MagicRoomInEffect_MoveTypeRemainsNormal()
{
// Arrange
var (script, move, target) = CreateTestSetup("flame_plate");
var battle = Substitute.For<IBattle>();
battle.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var battleVolatile = new ScriptSet(battle);
battleVolatile.Add(new MagicRoomEffect());
battle.Volatile.Returns(battleVolatile);
move.Battle.Returns(battle);
TypeIdentifier? typeIdentifier = new TypeIdentifier(1, "normal");
// Act
script.ChangeMoveType(move, target, 0, ref typeIdentifier);
// Assert
await Assert.That(typeIdentifier!.Value.Name.ToString()).IsEqualTo("normal");
}
}

View File

@@ -0,0 +1,385 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static;
using PkmnLib.Static.Moves;
using PkmnLib.Static.Species;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="KingsShield"/> move script and the <see cref="KingsShield"/> volatile
/// script. Behavior is verified against the Bulbapedia page for King's Shield (Generation VII).
/// </summary>
public class KingsShieldTests
{
/// <summary>
/// Creates a fully mocked setup for driving <see cref="KingsShield"/>'s inherited
/// <see cref="ProtectionScript.OnSecondaryEffect"/>. The <c>target</c> of the secondary effect is the
/// Pokémon using King's Shield itself, as the move is self-targeted, so the returned <c>user</c> is
/// passed as both the move's user and the secondary effect's target.
/// </summary>
private static (KingsShield script, IExecutingMove move, IPokemon user, IHitData hitData, IScriptSet volatileSet,
IForm defaultForm) CreateProtectSetup(bool userMovesLast = false, float randomRoll = 0.0f,
string speciesName = "mimikyu", string formName = "default")
{
var script = new KingsShield();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
// Mirror the real HitData behavior: once Fail() is called, HasFailed reports true.
hitData.When(h => h.Fail()).Do(_ => hitData.HasFailed.Returns(true));
move.GetHitData(user, 0).Returns(hitData);
move.User.Returns(user);
var defaultForm = Substitute.For<IForm>();
defaultForm.Name.Returns(new StringKey("default"));
var species = Substitute.For<ISpecies>();
species.Name.Returns(new StringKey(speciesName));
species.GetDefaultForm().Returns(defaultForm);
user.Species.Returns(species);
var currentForm = Substitute.For<IForm>();
currentForm.Name.Returns(new StringKey(formName));
user.Form.Returns(currentForm);
// A queue with a remaining choice means another Pokémon still has to move after the user.
var queue = userMovesLast ? new BattleChoiceQueue([]) : new BattleChoiceQueue([Substitute.For<IMoveChoice>()]);
var random = Substitute.For<IBattleRandom>();
random.GetFloat().Returns(randomRoll);
var battle = Substitute.For<IBattle>();
battle.ChoiceQueue.Returns(queue);
battle.Random.Returns(random);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
user.BattleData.Returns(battleData);
// Give the mock a real script iterator (used by the volatile add hook) and a real volatile script set.
user.GetScripts().Returns(_ => new ScriptIterator([]));
IScriptSet volatileSet = new ScriptSet(user);
user.Volatile.Returns(volatileSet);
return (script, move, user, hitData, volatileSet, defaultForm);
}
/// <summary>
/// Creates a fully mocked setup for driving <see cref="KingsShieldEffect.BlockIncomingHit"/>, the
/// volatile script that King's Shield attaches to its user.
/// </summary>
private static (KingsShieldEffect effect, IExecutingMove move, IPokemon target, IPokemon attacker) CreateBlockSetup(
bool isContact, bool hasProtectFlag, MoveCategory category = MoveCategory.Physical)
{
var effect = new KingsShieldEffect();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
hitData.IsContact.Returns(isContact);
move.GetHitData(target, 0).Returns(hitData);
var useMove = Substitute.For<IMoveData>();
useMove.HasFlag(new StringKey("protect")).Returns(hasProtectFlag);
useMove.Category.Returns(category);
move.UseMove.Returns(useMove);
var attacker = Substitute.For<IPokemon>();
attacker.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
move.User.Returns(attacker);
target.BattleData.Returns(Substitute.For<IPokemonBattleData>());
return (effect, move, target, attacker);
}
/// <summary>
/// Helper to extract the statistic and stage change from a Pokémon's received
/// <see cref="IPokemon.ChangeStatBoost"/> calls.
/// </summary>
private static (Statistic stat, sbyte change)? GetStatBoostCall(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
return call != null ? ((Statistic)call.GetArguments()[0]!, (sbyte)call.GetArguments()[1]!) : null;
}
/// <summary>
/// Helper that checks whether a Pokémon received a <see cref="IPokemon.ChangeForm"/> call.
/// </summary>
private static bool ReceivedChangeForm(IPokemon pokemon) =>
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeForm");
/// <summary>
/// Bulbapedia: "King's Shield protects the user from all effects of physical and special moves that target
/// it during the turn it is used."
/// Using the move must attach the <see cref="KingsShield"/> volatile script to the user, so that
/// blocked contact moves lower the attacker's Attack.
/// </summary>
[Test]
public async Task OnSecondaryEffect_FirstUse_AddsKingsShieldEffect()
{
// Arrange
var (script, move, user, hitData, volatileSet, _) = CreateProtectSetup();
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(volatileSet.Get<KingsShieldEffect>()).IsNotNull();
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: "If the user goes last in the turn, the move will fail."
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserMovesLast_Fails()
{
// Arrange
var (script, move, user, hitData, volatileSet, _) = CreateProtectSetup(true);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(volatileSet.Get<KingsShieldEffect>()).IsNull();
}
/// <summary>
/// Bulbapedia: "The chance that King's Shield will succeed also drops each time the user successfully and
/// consecutively uses Endure, any protection move that only affects the user, Quick Guard, or Wide Guard."
/// A successful use registers a consecutive protection turn on the <see cref="ProtectionFailureScript"/>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_FirstUse_RegistersConsecutiveProtectionUse()
{
// Arrange
var (script, move, user, _, volatileSet, _) = CreateProtectSetup();
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
var failure = volatileSet.Get<ProtectionFailureScript>();
await Assert.That(failure).IsNotNull();
await Assert.That(failure!.ProtectTurns).IsEqualTo(1);
await Assert.That(failure.UsedProtect).IsTrue();
}
/// <summary>
/// Bulbapedia: "Each time, the chance of success is divided by 3."
/// After one consecutive protection use the chance is 1/3, so a roll above 1/3 makes the move fail.
/// </summary>
[Test]
public async Task OnSecondaryEffect_SecondConsecutiveUse_RollAboveOneThird_Fails()
{
// Arrange
var (script, move, user, hitData, volatileSet, _) = CreateProtectSetup(randomRoll: 0.5f);
volatileSet.Add(new ProtectionFailureScript { ProtectTurns = 1 });
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
hitData.Received(1).Fail();
}
/// <summary>
/// Bulbapedia: "King's Shield also triggers Aegislash's form change. If Aegislash has Stance Change and
/// uses King's Shield while in Blade Forme, it will change to Shield Forme before using King's Shield."
/// </summary>
[Test]
public async Task OnSecondaryEffect_AegislashInBladeForme_ChangesToDefaultForm()
{
// Arrange
var (script, move, user, _, _, defaultForm) = CreateProtectSetup(speciesName: "aegislash", formName: "blade");
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
user.Received(1).ChangeForm(defaultForm);
await Assert.That(ReceivedChangeForm(user)).IsTrue();
}
/// <summary>
/// Bulbapedia: "If Aegislash has Stance Change and uses King's Shield while in Blade Forme, it will change
/// to Shield Forme".
/// An Aegislash that is already in its default (Shield) Forme must not change form again.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AegislashInDefaultForm_DoesNotChangeForm()
{
// Arrange
var (script, move, user, _, _, _) = CreateProtectSetup(speciesName: "aegislash", formName: "default");
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(ReceivedChangeForm(user)).IsFalse();
}
/// <summary>
/// Bulbapedia: "King's Shield also triggers Aegislash's form change."
/// The form change is specific to Aegislash; any other species using the move must not change form.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NonAegislashUser_DoesNotChangeForm()
{
// Arrange
var (script, move, user, _, _, _) = CreateProtectSetup(speciesName: "mimikyu", formName: "blade");
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(ReceivedChangeForm(user)).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation VI section): "Also in this generation only, Aegislash will change form if
/// King's Shield was selected but fails to execute. This may occur if it goes last or it is prevented by a
/// status condition."
/// In Generation VII the form change therefore must not happen when the move fails because the user goes
/// last in the turn.
/// </summary>
[Test]
public async Task OnSecondaryEffect_MoveFailsBecauseUserMovesLast_AegislashDoesNotChangeForm()
{
// Arrange
var (script, move, user, hitData, _, _) = CreateProtectSetup(true, speciesName: "aegislash", formName: "blade");
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert - the move failed, so no form change may happen
hitData.Received(1).Fail();
await Assert.That(ReceivedChangeForm(user)).IsFalse();
}
/// <summary>
/// Bulbapedia: "King's Shield protects the user from all effects of physical and special moves that target
/// it during the turn it is used."
/// A physical move that can be protected against (it has the protect flag) is blocked by the
/// <see cref="KingsShield"/>.
/// </summary>
[Test]
public async Task BlockIncomingHit_PhysicalMoveWithProtectFlag_BlocksHit()
{
// Arrange
var (effect, move, target, _) = CreateBlockSetup(false, true);
var block = false;
// Act
effect.BlockIncomingHit(move, target, 0, ref block);
// Assert
await Assert.That(block).IsTrue();
}
/// <summary>
/// Bulbapedia: "Some moves will do damage through King's Shield."
/// Moves that bypass protection (they lack the protect flag) are not blocked by the
/// <see cref="KingsShield"/>.
/// </summary>
[Test]
public async Task BlockIncomingHit_MoveWithoutProtectFlag_DoesNotBlock()
{
// Arrange
var (effect, move, target, _) = CreateBlockSetup(false, false);
var block = false;
// Act
effect.BlockIncomingHit(move, target, 0, ref block);
// Assert
await Assert.That(block).IsFalse();
}
/// <summary>
/// Bulbapedia: "King's Shield protects the user from all effects of physical and special moves that target
/// it during the turn it is used."
/// Unlike Protect, King's Shield does not protect against status moves, so those must not be blocked.
/// </summary>
[Test]
public async Task BlockIncomingHit_StatusMoveWithProtectFlag_DoesNotBlock()
{
// Arrange
var (effect, move, target, _) = CreateBlockSetup(false, true, MoveCategory.Status);
var block = false;
// Act
effect.BlockIncomingHit(move, target, 0, ref block);
// Assert
await Assert.That(block).IsFalse();
}
/// <summary>
/// Bulbapedia: "If King's Shield blocks an attack that would make contact with the user, the attacker's
/// Attack stat is lowered—in Generation VI and VII, it is lowered by 2 stages".
/// </summary>
[Test]
public async Task BlockIncomingHit_BlockedContactMove_LowersAttackerAttackByTwoStages()
{
// Arrange
var (effect, move, target, attacker) = CreateBlockSetup(true, true);
var block = false;
// Act
effect.BlockIncomingHit(move, target, 0, ref block);
// Assert
await Assert.That(block).IsTrue();
var statBoost = GetStatBoostCall(attacker);
await Assert.That(statBoost).IsNotNull();
await Assert.That(statBoost!.Value.stat).IsEqualTo(Statistic.Attack);
await Assert.That(statBoost.Value.change).IsEqualTo((sbyte)-2);
}
/// <summary>
/// Bulbapedia: "If King's Shield blocks an attack that would make contact with the user, the attacker's
/// Attack stat is lowered".
/// A blocked attack that does not make contact must not change the attacker's stats.
/// </summary>
[Test]
public async Task BlockIncomingHit_BlockedNonContactMove_DoesNotChangeAttackerStats()
{
// Arrange
var (effect, move, target, attacker) = CreateBlockSetup(false, true);
var block = false;
// Act
effect.BlockIncomingHit(move, target, 0, ref block);
// Assert
await Assert.That(block).IsTrue();
await Assert.That(GetStatBoostCall(attacker)).IsNull();
}
/// <summary>
/// Bulbapedia: "If King's Shield blocks an attack that would make contact with the user, the attacker's
/// Attack stat is lowered".
/// A contact move that bypasses the protection (it lacks the protect flag, e.g. Shadow Force) is not
/// blocked, so the attacker's stats must not be changed.
/// </summary>
[Test]
public async Task BlockIncomingHit_ContactMoveThatBypassesProtection_DoesNotChangeAttackerStats()
{
// Arrange
var (effect, move, target, attacker) = CreateBlockSetup(true, false);
var block = false;
// Act
effect.BlockIncomingHit(move, target, 0, ref block);
// Assert - the hit was not blocked, so no stat drop is applied
await Assert.That(block).IsFalse();
await Assert.That(GetStatBoostCall(attacker)).IsNull();
}
}

View File

@@ -0,0 +1,136 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="KnockOff"/> move script.
/// Behavior is verified against the Bulbapedia page for Knock Off (Generation VII).
/// </summary>
public class KnockOffTests
{
/// <summary>
/// Creates a fully mocked test setup for Knock Off tests. When <paramref name="targetHasItem"/> is true,
/// <see cref="IPokemon.TryStealHeldItem"/> succeeds and yields a mocked item.
/// </summary>
private static (KnockOff script, IExecutingMove move, IPokemon target, IPokemon user, IHitData hitData)
CreateTestSetup(bool targetHasItem)
{
var script = new KnockOff();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
target.TryStealHeldItem(out Arg.Any<IItem?>()).Returns(callInfo =>
{
callInfo[0] = targetHasItem ? Substitute.For<IItem>() : null;
return targetHasItem;
});
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
return (script, move, target, user, hitData);
}
/// <summary>
/// Bulbapedia: "Knock Off inflicts damage and knocks off the target's held item if it has one."
/// (Generation V onwards: "Knock Off now removes the held item rather than rendering it unusable".)
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHoldsItem_ItemIsRemoved()
{
// Arrange
var (script, move, target, _, hitData) = CreateTestSetup(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.Received(1).TryStealHeldItem(out Arg.Any<IItem?>());
hitData.DidNotReceive().Fail();
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "TryStealHeldItem")).IsTrue();
}
/// <summary>
/// Bulbapedia: "Knock Off inflicts damage and knocks off the target's held item if it has one."
/// Against a target without a held item the move simply deals its damage without any further effect; it
/// does not fail.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHoldsNoItem_HitIsNotMarkedAsFailed()
{
// Arrange
var (script, move, target, _, hitData) = CreateTestSetup(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.DidNotReceive().Fail();
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation V onwards): "If the user faints due to the target's Ability (Rough Skin or
/// Iron Barbs) or held Rocky Helmet, it cannot remove the target's held item."
/// Those effects damage the user before the secondary effect runs, so a user that has fainted by then must
/// not remove the item.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserIsFainted_ItemIsNotRemoved()
{
// Arrange
var (script, move, target, user, _) = CreateTestSetup(true);
user.IsFainted.Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "TryStealHeldItem")).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "If Knock Off is used on a Pokémon that is holding an item that can
/// be knocked off, its base power will be boosted by 50%."
/// With Knock Off's base power of 65 the boosted power is 97 (65 × 1.5, fractions dropped).
/// </summary>
[Test]
public async Task ChangeBasePower_TargetHoldsRemovableItem_BasePowerBoostedByFiftyPercent()
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup(true);
target.HeldItem.Returns(Substitute.For<IItem>());
ushort basePower = 65;
// Act
((IScriptChangeBasePower)script).ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)97);
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "If Knock Off is used on a Pokémon that is holding an item that can
/// be knocked off, its base power will be boosted by 50%."
/// A target without a held item does not grant the boost, so the base power stays unchanged.
/// </summary>
[Test]
public async Task ChangeBasePower_TargetHoldsNoItem_BasePowerUnchanged()
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup(false);
target.HeldItem.Returns((IItem?)null);
ushort basePower = 65;
// Act
((IScriptChangeBasePower)script).ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)65);
}
}

View File

@@ -0,0 +1,134 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="LaserFocus"/> move script and its volatile effect script
/// <see cref="LaserFocusEffect"/>.
/// Gen VII Bulbapedia behavior: "Laser Focus causes the user's move to result in a critical hit until the
/// end of the next turn, unless that move's target has Battle Armor, Shell Armor, or is under the effect
/// of Lucky Chant."
/// </summary>
public class LaserFocusTests
{
/// <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: "Laser Focus causes the user's move to result in a critical hit" — using the move places
/// the <see cref="LaserFocusEffect"/> on the target (Laser Focus targets the user itself).
/// </summary>
[Test]
public async Task OnSecondaryEffect_Used_AddsLaserFocusEffectToTarget()
{
// Arrange
var script = new LaserFocus();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var targetVolatile = Substitute.For<IScriptSet>();
target.Volatile.Returns(targetVolatile);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
targetVolatile.Received(1).Add(Arg.Any<LaserFocusEffect>());
await Assert.That(targetVolatile.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "Add")).IsEqualTo(1);
}
/// <summary>
/// Bulbapedia: the user's move will "result in a critical hit" — the effect raises the critical stage
/// to at least 3, which the Gen 7 damage calculator treats as a guaranteed critical hit.
/// </summary>
[Test]
public async Task ChangeCriticalStage_EffectActive_GuaranteesCriticalHit()
{
// Arrange
var effect = new LaserFocusEffect();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
byte stage = 0;
// Act
effect.ChangeCriticalStage(move, target, 0, ref stage);
// Assert
await Assert.That(stage).IsGreaterThanOrEqualTo((byte)3);
}
/// <summary>
/// Bulbapedia: the guarantee lasts "until the end of the next turn" — it is not consumed by a single
/// critical-stage check, so e.g. every hit of a multi-hit move is a critical hit.
/// </summary>
[Test]
public async Task ChangeCriticalStage_AfterFirstCheck_EffectStillActive()
{
// Arrange
var effect = new LaserFocusEffect();
var set = CreateHostedSet(effect);
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
byte stage = 0;
// Act
effect.ChangeCriticalStage(move, target, 0, ref stage);
// Assert
await Assert.That(set.Contains(ScriptUtils.ResolveName<LaserFocusEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: the guarantee lasts "until the end of the next turn" — at the end of the turn Laser
/// Focus was used the effect is still active, so the move used on the following turn is a critical hit.
/// </summary>
[Test]
public async Task OnEndTurn_EndOfTurnUsed_EffectStillActive()
{
// Arrange
var effect = new LaserFocusEffect();
var set = CreateHostedSet(effect);
// Act - the end of the turn Laser Focus was used
if (effect is IScriptOnEndTurn onEndTurn)
onEndTurn.OnEndTurn(Substitute.For<IScriptSource>(), Substitute.For<IBattle>());
// Assert
await Assert.That(set.Contains(ScriptUtils.ResolveName<LaserFocusEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: the guarantee only lasts "until the end of the next turn" — if the user does not move,
/// the effect expires at the end of the turn after it was used.
/// </summary>
[Test]
public async Task OnEndTurn_EndOfNextTurn_EffectExpires()
{
// Arrange
var effect = new LaserFocusEffect();
var set = CreateHostedSet(effect);
// Act - end of the turn Laser Focus was used, and end of the next turn
if (effect is IScriptOnEndTurn onEndTurn)
{
onEndTurn.OnEndTurn(Substitute.For<IScriptSource>(), Substitute.For<IBattle>());
onEndTurn.OnEndTurn(Substitute.For<IScriptSource>(), Substitute.For<IBattle>());
}
// Assert
await Assert.That(set.Contains(ScriptUtils.ResolveName<LaserFocusEffect>())).IsFalse();
}
}

View File

@@ -0,0 +1,233 @@
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="LastResort"/> move script.
/// Gen VII Bulbapedia behavior: "Last Resort will fail unless the Pokémon has used all of its other moves
/// at least once while on the field. Using a move before switching out and back in does not count towards
/// being able to use Last Resort. Last Resort will fail if it is the only move the user knows".
/// </summary>
public class LastResortTests
{
/// <summary>
/// Creates a mocked <see cref="ILearnedMove"/> with the given move name.
/// </summary>
private static ILearnedMove CreateLearnedMove(string name)
{
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new StringKey(name));
moveData.SecondaryEffect.Returns((ISecondaryEffect?)null);
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
return learnedMove;
}
/// <summary>
/// Creates a mocked user in battle that knows the given moves.
/// </summary>
private static IPokemon CreateUser(params ILearnedMove?[] moves)
{
var user = Substitute.For<IPokemon>();
user.Moves.Returns(moves);
var battle = Substitute.For<IBattle>();
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
user.BattleData.Returns(battleData);
return user;
}
/// <summary>
/// Sets the battle's previous turn choices, one inner array per turn (oldest first).
/// </summary>
private static void SetPreviousTurns(IPokemon user, params ITurnChoice[][] turns)
{
user.BattleData!.Battle.PreviousTurnChoices.Returns(turns.Select(IReadOnlyList<ITurnChoice> (t) => t).ToList());
}
/// <summary>
/// Runs <see cref="LastResort.PreventMoveSelection"/> for the user selecting the given Last Resort
/// move and returns whether the selection was prevented.
/// </summary>
private static bool RunPreventMoveSelection(IPokemon user, ILearnedMove lastResort)
{
var script = new LastResort();
var choice = new MoveChoice(user, lastResort, 0, 0);
var prevent = false;
script.PreventMoveSelection(choice, ref prevent);
return prevent;
}
/// <summary>
/// Bulbapedia: "Last Resort will fail unless the Pokémon has used all of its other moves at least once
/// while on the field." — with every other move used, selecting Last Resort is allowed.
/// </summary>
[Test]
public async Task PreventMoveSelection_AllOtherMovesUsedOnField_SelectionAllowed()
{
// Arrange
var tackle = CreateLearnedMove("tackle");
var growl = CreateLearnedMove("growl");
var lastResort = CreateLearnedMove("last_resort");
var user = CreateUser(tackle, growl, lastResort);
SetPreviousTurns(user, [new MoveChoice(user, tackle, 0, 0)], [new MoveChoice(user, growl, 0, 0)]);
// Act
var prevent = RunPreventMoveSelection(user, lastResort);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia: "Last Resort will fail unless the Pokémon has used all of its other moves at least once
/// while on the field." — with one of the other moves never used, the selection is prevented.
/// </summary>
[Test]
public async Task PreventMoveSelection_NotAllOtherMovesUsed_SelectionPrevented()
{
// Arrange
var tackle = CreateLearnedMove("tackle");
var growl = CreateLearnedMove("growl");
var lastResort = CreateLearnedMove("last_resort");
var user = CreateUser(tackle, growl, lastResort);
SetPreviousTurns(user, [new MoveChoice(user, tackle, 0, 0)]);
// Act
var prevent = RunPreventMoveSelection(user, lastResort);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: Last Resort fails "unless the Pokémon has used all of its other moves at least once" —
/// with no moves used at all (e.g. the first turn), the selection is prevented.
/// </summary>
[Test]
public async Task PreventMoveSelection_NoMovesUsedYet_SelectionPrevented()
{
// Arrange
var tackle = CreateLearnedMove("tackle");
var lastResort = CreateLearnedMove("last_resort");
var user = CreateUser(tackle, lastResort);
SetPreviousTurns(user);
// Act
var prevent = RunPreventMoveSelection(user, lastResort);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "Last Resort will fail if it is the only move the user knows".
/// </summary>
[Test]
public async Task PreventMoveSelection_LastResortOnlyKnownMove_SelectionPrevented()
{
// Arrange
var lastResort = CreateLearnedMove("last_resort");
var user = CreateUser(lastResort, null, null, null);
SetPreviousTurns(user);
// Act
var prevent = RunPreventMoveSelection(user, lastResort);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "Using a move before switching out and back in does not count towards being able to use
/// Last Resort." — a move used only before the user was switched back in does not enable Last Resort.
/// </summary>
[Test]
public async Task PreventMoveSelection_MoveOnlyUsedBeforeSwitchingOut_SelectionPrevented()
{
// Arrange
var tackle = CreateLearnedMove("tackle");
var lastResort = CreateLearnedMove("last_resort");
var user = CreateUser(tackle, lastResort);
var ally = Substitute.For<IPokemon>();
SetPreviousTurns(user, [new MoveChoice(user, tackle, 0, 0)], [new SwitchChoice(user, ally)],
[new SwitchChoice(ally, user)]);
// The user re-entered the field during turn 3, after all three recorded turns' choices were made.
user.BattleData!.SwitchInTurn.Returns(3u);
// Act
var prevent = RunPreventMoveSelection(user, lastResort);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "A move will still count as used for Last Resort even if it would have no effect, so for
/// example, using Snore when the user is awake or a move missing due to accuracy or evasion." — a failed
/// move choice still counts as used.
/// </summary>
[Test]
public async Task PreventMoveSelection_OtherMoveFailed_SelectionAllowed()
{
// Arrange
var tackle = CreateLearnedMove("tackle");
var lastResort = CreateLearnedMove("last_resort");
var user = CreateUser(tackle, lastResort);
var failedTackle = new MoveChoice(user, tackle, 0, 0);
failedTackle.Fail();
SetPreviousTurns(user, [failedTackle]);
// Act
var prevent = RunPreventMoveSelection(user, lastResort);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia: the requirement is that "the Pokémon has used all of its other moves" — a move used by a
/// different Pokémon does not count for the user.
/// </summary>
[Test]
public async Task PreventMoveSelection_MoveUsedByAnotherPokemon_SelectionPrevented()
{
// Arrange
var tackle = CreateLearnedMove("tackle");
var lastResort = CreateLearnedMove("last_resort");
var user = CreateUser(tackle, lastResort);
var other = Substitute.For<IPokemon>();
SetPreviousTurns(user, [new MoveChoice(other, tackle, 0, 0)]);
// Act
var prevent = RunPreventMoveSelection(user, lastResort);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Technical test: outside of battle (no <see cref="IPokemon.BattleData"/>) the selection is prevented
/// instead of throwing.
/// </summary>
[Test]
public async Task PreventMoveSelection_NoBattleData_SelectionPrevented()
{
// Arrange
var tackle = CreateLearnedMove("tackle");
var lastResort = CreateLearnedMove("last_resort");
var user = Substitute.For<IPokemon>();
user.Moves.Returns([tackle, lastResort]);
user.BattleData.Returns((IPokemonBattleData?)null);
// Act
var prevent = RunPreventMoveSelection(user, lastResort);
// Assert
await Assert.That(prevent).IsTrue();
}
}

View File

@@ -0,0 +1,205 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static;
using PkmnLib.Static.Species;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="LeechSeed"/> move script and its volatile effect script
/// <see cref="LeechSeedEffect"/>.
/// Bulbapedia: "Leech Seed plants a seed on the target.", with the in-game description "A seed is planted
/// on the target. It steals some HP from the target every turn." In Generation VII the seed drains 1/8 of
/// the seeded Pokémon's maximum HP at the end of each turn and restores it to the placer; Grass-type
/// Pokémon are unaffected by Leech Seed.
/// </summary>
public class LeechSeedTests
{
/// <summary>
/// Creates a fully mocked test setup for the <see cref="LeechSeed"/> move script, with a target of the
/// given types.
/// </summary>
private static (LeechSeed script, IExecutingMove move, IPokemon target, IScriptSet targetVolatile, IHitData hitData)
CreateMoveSetup(params TypeIdentifier[] targetTypes)
{
var script = new LeechSeed();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
target.Types.Returns(targetTypes);
var targetVolatile = Substitute.For<IScriptSet>();
target.Volatile.Returns(targetVolatile);
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
return (script, move, target, targetVolatile, hitData);
}
/// <summary>
/// Creates a mocked seeded Pokémon and placer for <see cref="LeechSeedEffect"/> tests.
/// </summary>
private static (LeechSeedEffect effect, IPokemon seeded, IPokemon placer) CreateEffectSetup(uint maxHp,
uint currentHp)
{
var seeded = Substitute.For<IPokemon>();
seeded.MaxHealth.Returns(maxHp);
seeded.CurrentHealth.Returns(currentHp);
var placer = Substitute.For<IPokemon>();
var effect = new LeechSeedEffect(seeded, placer);
return (effect, seeded, placer);
}
/// <summary>
/// Helper to extract the heal amount from a substitute's received Heal calls, or null when Heal was
/// never called.
/// </summary>
private static uint? GetHealAmount(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Heal");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Helper to extract the damage amount from a substitute's received Damage calls, or null when Damage
/// was never called.
/// </summary>
private static uint? GetDamageAmount(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Bulbapedia: "Leech Seed plants a seed on the target." — using the move places the
/// <see cref="LeechSeedEffect"/> on the target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NonGrassTarget_AddsLeechSeedEffect()
{
// Arrange
var (script, move, target, targetVolatile, hitData) = CreateMoveSetup(new TypeIdentifier(11, "water"));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
targetVolatile.Received(1).Add(Arg.Any<LeechSeedEffect>());
hitData.DidNotReceive().Fail();
await Assert.That(targetVolatile.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Add")).IsTrue();
}
/// <summary>
/// Grass-type Pokémon are unaffected by Leech Seed — against a Grass-type target the move fails and no
/// seed is planted.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GrassTarget_MoveFailsAndNoSeedPlanted()
{
// Arrange
var (script, move, target, targetVolatile, hitData) = CreateMoveSetup(new TypeIdentifier(4, "grass"));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(targetVolatile.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Add")).IsFalse();
}
/// <summary>
/// Grass-type Pokémon are unaffected by Leech Seed — this includes dual types where grass is the
/// secondary type.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DualTypeGrassTarget_MoveFails()
{
// Arrange
var (script, move, target, _, hitData) =
CreateMoveSetup(new TypeIdentifier(7, "poison"), new TypeIdentifier(4, "grass"));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue();
}
/// <summary>
/// Bulbapedia: "It steals some HP from the target every turn." — at the end of each turn the seeded
/// Pokémon loses 1/8 of its maximum HP (integer division) and the placer is healed by the same amount.
/// </summary>
[Test, Arguments(80u, 10u), Arguments(100u, 12u), Arguments(1000u, 125u)]
public async Task OnEndTurn_SeededPokemon_DrainsOneEighthOfMaxHpToPlacer(uint maxHp, uint expectedDrain)
{
// Arrange
var (effect, seeded, placer) = CreateEffectSetup(maxHp, maxHp);
// Act
effect.OnEndTurn(Substitute.For<IScriptSource>(), Substitute.For<IBattle>());
// Assert
await Assert.That(GetDamageAmount(seeded)!.Value).IsEqualTo(expectedDrain);
await Assert.That(GetHealAmount(placer)!.Value).IsEqualTo(expectedDrain);
}
/// <summary>
/// The drained amount cannot exceed the seeded Pokémon's remaining HP — with less HP left than the 1/8
/// drain, only the remaining HP is drained and transferred.
/// </summary>
[Test]
public async Task OnEndTurn_SeededPokemonHpBelowDrain_DrainCappedAtRemainingHp()
{
// Arrange - 1/8 of 80 is 10, but only 4 HP remains
var (effect, seeded, placer) = CreateEffectSetup(80, 4);
// Act
effect.OnEndTurn(Substitute.For<IScriptSource>(), Substitute.For<IBattle>());
// Assert
await Assert.That(GetDamageAmount(seeded)!.Value).IsEqualTo(4u);
await Assert.That(GetHealAmount(placer)!.Value).IsEqualTo(4u);
}
/// <summary>
/// When the seeded Pokémon has the Liquid Ooze Ability, the placer loses the drained HP instead of
/// being healed by it.
/// </summary>
[Test]
public async Task OnEndTurn_SeededPokemonHasLiquidOoze_PlacerDamagedInsteadOfHealed()
{
// Arrange
var (effect, seeded, placer) = CreateEffectSetup(80, 80);
var ability = Substitute.For<IAbility>();
ability.Name.Returns(new StringKey("liquid_ooze"));
seeded.ActiveAbility.Returns(ability);
// Act
effect.OnEndTurn(Substitute.For<IScriptSource>(), Substitute.For<IBattle>());
// Assert
await Assert.That(GetDamageAmount(placer)!.Value).IsEqualTo(10u);
await Assert.That(placer.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Heal")).IsFalse();
}
/// <summary>
/// The end-of-turn drain is indirect damage, not move damage — both the seeded Pokémon's HP loss and a
/// Liquid Ooze recoil use <see cref="DamageSource.Misc"/>.
/// </summary>
[Test]
public async Task OnEndTurn_SeededPokemon_UsesMiscDamageSource()
{
// Arrange
var (effect, seeded, _) = CreateEffectSetup(80, 80);
// Act
effect.OnEndTurn(Substitute.For<IScriptSource>(), Substitute.For<IBattle>());
// Assert
var call = seeded.ReceivedCalls().First(c => c.GetMethodInfo().Name == "Damage");
await Assert.That((DamageSource)call.GetArguments()[1]!).IsEqualTo(DamageSource.Misc);
}
}

View File

@@ -0,0 +1,267 @@
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.Static.Moves;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="LightScreen"/> move script and its side effect script
/// <see cref="LightScreenEffect"/>.
/// Gen VII Bulbapedia behavior: Light Screen halves the damage the user's side takes from special moves
/// for 5 turns; "When multiple Pokémon are present on the user's side, special damage reduces by one-third
/// instead of one-half." and "If Light Clay is held when Light Screen is used, it will extend the duration
/// of Light Screen from 5 to 8 turns."
/// </summary>
public class LightScreenTests
{
/// <summary>
/// Test helper script that extends the Light Screen duration through the custom trigger, the same way
/// the Light Clay item script does.
/// </summary>
[Script(ScriptCategory.Pokemon, "test_light_screen_duration_extender")]
private class DurationExtender : Script, IScriptCustomTrigger
{
public void CustomTrigger(StringKey eventName, ICustomTriggerArgs args)
{
if (eventName == CustomTriggers.LightScreenNumberOfTurns &&
args is CustomTriggers.LightScreenNumberOfTurnsArgs d)
d.Duration = 8;
}
}
private static (LightScreen script, IExecutingMove move, IScriptSet sideScripts) CreateMoveSetup()
{
var script = new LightScreen();
var move = Substitute.For<IExecutingMove>();
move.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
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);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.BattleSide.Returns(side);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
move.User.Returns(user);
return (script, move, sideScripts);
}
/// <summary>
/// Helper to extract the effect script instance created through StackOrAdd on the side's volatile
/// scripts.
/// </summary>
private static LightScreenEffect? 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 LightScreenEffect;
}
/// <summary>
/// Hosts the given effect in a real <see cref="ScriptSet"/> and counts how many end-of-turn ticks it
/// survives, up to the given maximum.
/// </summary>
private static int CountSurvivedEndTurns(LightScreenEffect effect, int maximum)
{
var owner = Substitute.For<IBattleSide>();
owner.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet set = new ScriptSet(owner);
set.Add(effect);
for (var turns = 0; turns < maximum; turns++)
{
effect.OnEndTurn(owner, Substitute.For<IBattle>());
if (!set.Contains(ScriptUtils.ResolveName<LightScreenEffect>()))
return turns + 1;
}
return maximum + 1;
}
/// <summary>
/// Creates a mocked incoming move of the given category for <see cref="LightScreenEffect"/> damage
/// tests, with the defending target in a battle with the given amount of positions per side.
/// </summary>
private static (IExecutingMove move, IPokemon target) CreateIncomingMove(MoveCategory category,
byte positionsPerSide, bool isCritical = false)
{
var move = Substitute.For<IExecutingMove>();
var moveData = Substitute.For<IMoveData>();
moveData.Category.Returns(category);
move.UseMove.Returns(moveData);
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
hitData.IsCritical.Returns(isCritical);
move.GetHitData(target, 0).Returns(hitData);
var battle = Substitute.For<IBattle>();
battle.PositionsPerSide.Returns(positionsPerSide);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
target.BattleData.Returns(battleData);
return (move, target);
}
/// <summary>
/// Using Light Screen places a <see cref="LightScreenEffect"/> on the user's side of the field.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserInBattle_AddsLightScreenEffectToUserSide()
{
// Arrange
var (script, move, sideScripts) = CreateMoveSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(GetAddedEffect(sideScripts)).IsNotNull();
}
/// <summary>
/// Bulbapedia (Generation II): Light Screen "remains in effect for 5 turns" — the effect placed on the
/// side expires at the end of the fifth turn.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserInBattle_EffectLastsFiveTurns()
{
// Arrange
var (script, move, sideScripts) = CreateMoveSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
var effect = GetAddedEffect(sideScripts);
await Assert.That(CountSurvivedEndTurns(effect!, 10)).IsEqualTo(5);
}
/// <summary>
/// Bulbapedia: "If Light Clay is held when Light Screen is used, it will extend the duration of Light
/// Screen from 5 to 8 turns." The extension flows through the LightScreenNumberOfTurns custom trigger;
/// a script that sets the duration to 8 results in an effect lasting 8 turns.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DurationExtendedByTrigger_EffectLastsEightTurns()
{
// Arrange
var (script, move, sideScripts) = CreateMoveSetup();
move.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>
{
new ScriptContainer(new DurationExtender()),
}));
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
var effect = GetAddedEffect(sideScripts);
await Assert.That(CountSurvivedEndTurns(effect!, 10)).IsEqualTo(8);
}
/// <summary>
/// Technical test: outside of battle (no <see cref="IPokemon.BattleData"/>) the hook does nothing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NoBattleData_DoesNothing()
{
// Arrange
var script = new LightScreen();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
move.User.Returns(user);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert - the script returns before running the duration trigger over the move's scripts
await Assert.That(move.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "GetScripts")).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation III): Light Screen "halves damage from special attacks" — in a single battle
/// the damage of an incoming special move is halved, with integer truncation.
/// </summary>
[Test, Arguments(100u, 50u), Arguments(101u, 50u), Arguments(99u, 49u)]
public async Task ChangeIncomingMoveDamage_SpecialMoveSingleBattle_DamageHalved(uint damage, uint expectedDamage)
{
// Arrange
var effect = new LightScreenEffect(5);
var (move, target) = CreateIncomingMove(MoveCategory.Special, 1);
// Act
effect.ChangeIncomingMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: "When multiple Pokémon are present on the user's side, special damage reduces by
/// one-third instead of one-half." — with more than one position per side the damage is multiplied
/// by 2/3.
/// </summary>
[Test, Arguments(90u, 60u), Arguments(100u, 66u)]
public async Task ChangeIncomingMoveDamage_SpecialMoveMultiBattle_DamageReducedByOneThird(uint damage,
uint expectedDamage)
{
// Arrange
var effect = new LightScreenEffect(5);
var (move, target) = CreateIncomingMove(MoveCategory.Special, 2);
// Act
effect.ChangeIncomingMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(expectedDamage);
}
/// <summary>
/// Light Screen only reduces damage from special moves — physical move damage is unchanged.
/// </summary>
[Test]
public async Task ChangeIncomingMoveDamage_PhysicalMove_DamageUnchanged()
{
// Arrange
var effect = new LightScreenEffect(5);
var (move, target) = CreateIncomingMove(MoveCategory.Physical, 1);
var damage = 100u;
// Act
effect.ChangeIncomingMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(100u);
}
/// <summary>
/// Critical hits ignore Light Screen — a critical special move deals full damage through the screen.
/// </summary>
[Test]
public async Task ChangeIncomingMoveDamage_CriticalHit_DamageUnchanged()
{
// Arrange
var effect = new LightScreenEffect(5);
var (move, target) = CreateIncomingMove(MoveCategory.Special, 1, true);
var damage = 100u;
// Act
effect.ChangeIncomingMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(100u);
}
}

View File

@@ -0,0 +1,158 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="LockOn"/> move script and its volatile effect script
/// <see cref="LockOnEffect"/>.
/// Gen VII Bulbapedia behavior: "After the user has used Lock-On and until its effect ends (see table
/// below), any accuracy check it makes against the same target will succeed; this means moves will not
/// miss, even if the target is in the semi-invulnerable turn of a move such as Dig or Fly.", where the
/// effect expires at the end of the next turn.
/// </summary>
public class LockOnTests
{
private static (LockOn script, IExecutingMove move, IPokemon user, IPokemon target, ScriptSet targetVolatile)
CreateTestSetup()
{
var script = new LockOn();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
// Use a real script set so the volatile script added by Lock-On can be inspected afterwards, and so
// that Script.RemoveSelf calls can be observed through IScriptSet.Contains.
var target = Substitute.For<IPokemon>();
var targetVolatile = new ScriptSet(target);
target.Volatile.Returns(targetVolatile);
target.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
return (script, move, user, target, targetVolatile);
}
/// <summary>
/// Bulbapedia: "any accuracy check it makes against the same target will succeed" — the engine runs
/// <see cref="IScriptChangeIncomingAccuracy"/> (which <see cref="LockOnEffect"/> implements) over the
/// <em>defending</em> Pokémon's scripts, so the effect must be placed on the target for the guarantee
/// to ever apply.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Hit_AddsLockOnEffectToTarget()
{
// Arrange
var (script, move, _, target, targetVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<LockOnEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "moves will not miss" — an accuracy check by the Pokémon that placed the Lock-On against
/// the locked-on target resolves to 255, which the accuracy calculation treats as a guaranteed hit.
/// </summary>
[Test]
public async Task ChangeIncomingAccuracy_AttackByLockOnUser_AccuracyGuaranteed()
{
// Arrange
var placer = Substitute.For<IPokemon>();
var effect = new LockOnEffect(placer);
var attack = Substitute.For<IExecutingMove>();
attack.User.Returns(placer);
var accuracy = 90;
// Act
effect.ChangeIncomingAccuracy(attack, Substitute.For<IPokemon>(), 0, ref accuracy);
// Assert
await Assert.That(accuracy).IsGreaterThanOrEqualTo(255);
}
/// <summary>
/// Bulbapedia: it is the accuracy checks of "the user" of Lock-On that are guaranteed — a move used by
/// any other Pokémon against the locked-on target is not affected.
/// </summary>
[Test]
public async Task ChangeIncomingAccuracy_AttackByOtherPokemon_AccuracyUnchanged()
{
// Arrange
var placer = Substitute.For<IPokemon>();
var effect = new LockOnEffect(placer);
var attack = Substitute.For<IExecutingMove>();
attack.User.Returns(Substitute.For<IPokemon>());
var accuracy = 90;
// Act
effect.ChangeIncomingAccuracy(attack, Substitute.For<IPokemon>(), 0, ref accuracy);
// Assert
await Assert.That(accuracy).IsEqualTo(90);
}
/// <summary>
/// Bulbapedia: "After the user has used Lock-On [...] any accuracy check it makes against the same
/// target will succeed" — the effect applied by the move must record the move's <em>user</em> as the
/// placer, so that that user's subsequent accuracy checks against the target are the guaranteed ones.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AppliedEffect_GuaranteesUsersAccuracyAgainstTarget()
{
// Arrange
var (script, move, _, target, targetVolatile) = CreateTestSetup();
script.OnSecondaryEffect(move, target, 0);
await Assert.That(targetVolatile.TryGet<LockOnEffect>(out var effect)).IsTrue();
// Act - the placer attacks the locked-on target
var accuracy = 90;
effect!.ChangeIncomingAccuracy(move, target, 0, ref accuracy);
// Assert
await Assert.That(accuracy).IsGreaterThanOrEqualTo(255);
}
/// <summary>
/// Bulbapedia: the effect expires at the end of the next turn — at the end of the turn Lock-On was
/// used the effect must still be active, so that the move used on the following turn cannot miss.
/// </summary>
[Test]
public async Task OnEndTurn_EndOfTurnUsed_EffectStillActive()
{
// Arrange
var (script, move, _, target, targetVolatile) = CreateTestSetup();
script.OnSecondaryEffect(move, target, 0);
await Assert.That(targetVolatile.TryGet<LockOnEffect>(out var effect)).IsTrue();
// Act - the end of the turn Lock-On was used
effect!.OnEndTurn(Substitute.For<IScriptSource>(), Substitute.For<IBattle>());
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<LockOnEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: the effect expires at the end of the next turn — after two end-of-turns the effect is
/// gone.
/// </summary>
[Test]
public async Task OnEndTurn_EndOfNextTurn_EffectExpires()
{
// Arrange
var (script, move, _, target, targetVolatile) = CreateTestSetup();
script.OnSecondaryEffect(move, target, 0);
await Assert.That(targetVolatile.TryGet<LockOnEffect>(out var effect)).IsTrue();
// Act - end of the turn Lock-On was used, and end of the next turn
effect!.OnEndTurn(Substitute.For<IScriptSource>(), Substitute.For<IBattle>());
effect.OnEndTurn(Substitute.For<IScriptSource>(), Substitute.For<IBattle>());
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<LockOnEffect>())).IsFalse();
}
}

View File

@@ -0,0 +1,56 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="LowKick"/> move script.
/// Gen VII Bulbapedia behavior (Generation III onwards): "Low Kick now has 100% accuracy, and its power is
/// now dependent on the weight of the target, inflicting greater damage on heavier targets."
/// </summary>
public class LowKickTests
{
/// <summary>
/// Bulbapedia weight-to-power table: 0.19.9 kg → 20, 10.024.9 kg → 40, 25.049.9 kg → 60,
/// 50.099.9 kg → 80, 100.0199.9 kg → 100, 200.0+ kg → 120. Each row tests a boundary of the table.
/// </summary>
[Test, Arguments(0.1f, 20), Arguments(9.9f, 20), Arguments(10.0f, 40), Arguments(24.9f, 40), Arguments(25.0f, 60),
Arguments(49.9f, 60), Arguments(50.0f, 80), Arguments(99.9f, 80), Arguments(100.0f, 100), Arguments(199.9f, 100),
Arguments(200.0f, 120), Arguments(950.0f, 120)]
public async Task ChangeBasePower_TargetWeight_PowerFollowsWeightTable(float weightInKg, int expectedPower)
{
// Arrange
var script = new LowKick();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.WeightInKg.Returns(weightInKg);
ushort basePower = 50;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)expectedPower);
}
/// <summary>
/// Bulbapedia: the power is "dependent on the weight of the target" only — the move's original base
/// power is irrelevant and fully replaced by the table value.
/// </summary>
[Test]
public async Task ChangeBasePower_HighOriginalBasePower_OverwrittenByWeightTable()
{
// Arrange
var script = new LowKick();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.WeightInKg.Returns(5f);
var basePower = ushort.MaxValue;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)20);
}
}

View File

@@ -0,0 +1,111 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Side;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="LuckyChant"/> move script and its volatile effect script
/// <see cref="LuckyChantEffect"/>.
/// Gen VII Bulbapedia behavior: "Lucky Chant prevents opponents from landing critical hits on the user's
/// party for five turns, even if the move would always result in a critical hit (e.g. Frost Breath)."
/// </summary>
public class LuckyChantTests
{
/// <summary>
/// Bulbapedia: the protection applies to "the user's party for five turns" — like the other
/// side-affecting moves, the effect belongs on the user's side of the field so that Pokémon switching
/// in during those five turns are protected as well.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Used_AddsLuckyChantEffectToUserSide()
{
// Arrange
var script = new LuckyChant();
var move = Substitute.For<IExecutingMove>();
var sideScripts = Substitute.For<IScriptSet>();
var side = Substitute.For<IBattleSide>();
side.VolatileScripts.Returns(sideScripts);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.BattleSide.Returns(side);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
user.Volatile.Returns(Substitute.For<IScriptSet>());
move.User.Returns(user);
// Act - Lucky Chant targets the user's side; the user itself is one of the targets
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(sideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name is "Add" or "StackOrAdd"))
.IsTrue();
}
/// <summary>
/// Bulbapedia: Lucky Chant "prevents opponents from landing critical hits" — the engine consults the
/// defending side through <see cref="IScriptBlockIncomingCriticalHit"/>
/// (<c>MoveTurnExecutor</c> only runs <see cref="IScriptBlockCriticalHit"/> over the attacking move's
/// own scripts), so the effect must block through that hook to protect the party.
/// </summary>
[Test]
public async Task BlockIncomingCriticalHit_EffectActive_CriticalHitBlocked()
{
// Arrange
var effect = new LuckyChantEffect();
var attack = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var block = false;
// Act
if (effect is IScriptBlockIncomingCriticalHit blockIncoming)
blockIncoming.BlockIncomingCriticalHit(attack, target, 0, ref block);
// Assert
await Assert.That(block).IsTrue();
}
/// <summary>
/// Bulbapedia: the protection lasts "for five turns" — after four end-of-turns (the turn Lucky Chant
/// was used and three more) the effect is still active.
/// </summary>
[Test]
public async Task OnEndTurn_FourTurnsPassed_EffectStillActive()
{
// Arrange
var effect = new LuckyChantEffect();
var owner = Substitute.For<IPokemon>();
owner.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet set = new ScriptSet(owner);
set.Add(effect);
// Act
for (var i = 0; i < 4; i++)
effect.OnEndTurn(owner, Substitute.For<IBattle>());
// Assert
await Assert.That(set.Contains(ScriptUtils.ResolveName<LuckyChantEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: the protection lasts "for five turns" — at the end of the fifth turn the effect expires.
/// </summary>
[Test]
public async Task OnEndTurn_FiveTurnsPassed_EffectExpires()
{
// Arrange
var effect = new LuckyChantEffect();
var owner = Substitute.For<IPokemon>();
owner.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet set = new ScriptSet(owner);
set.Add(effect);
// Act
for (var i = 0; i < 5; i++)
effect.OnEndTurn(owner, Substitute.For<IBattle>());
// Assert
await Assert.That(set.Contains(ScriptUtils.ResolveName<LuckyChantEffect>())).IsFalse();
}
}

View File

@@ -0,0 +1,242 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Side;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="LunarDance"/> move script and its side effect script
/// <see cref="LunarDanceEffect"/>.
/// Gen VII Bulbapedia behavior: "Lunar Dance causes the user to faint, but restores the HP and PP of the
/// Pokémon that is sent out to take its place as well as removing all status conditions (except
/// fainting).", where from Generation V to VII "The receiving Pokémon is now sent out at the end of the
/// turn after all other Pokémon have moved."
/// </summary>
public class LunarDanceTests
{
/// <summary>
/// Creates a fully mocked test setup for the <see cref="LunarDance"/> move script, with the user at the
/// given position and the side's volatile scripts backed by a real <see cref="ScriptSet"/>.
/// </summary>
private static (LunarDance script, IExecutingMove move, IPokemon user, IScriptSet sideVolatile) CreateMoveSetup(
byte position = 1)
{
var script = new LunarDance();
var move = Substitute.For<IExecutingMove>();
var side = Substitute.For<IBattleSide>();
// ScriptSet.Add runs the IScriptPreventVolatileAdd hook over the owner's scripts; give the mock a
// real (empty) iterator so the hook pass runs.
side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet sideVolatile = new ScriptSet(side);
side.VolatileScripts.Returns(sideVolatile);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.BattleSide.Returns(side);
battleData.Position.Returns(position);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
move.User.Returns(user);
return (script, move, user, sideVolatile);
}
/// <summary>
/// Creates a mocked replacement Pokémon with the given maximum HP for
/// <see cref="LunarDanceEffect"/> tests.
/// </summary>
private static IPokemon CreateReplacement(uint maxHp = 200)
{
var pokemon = Substitute.For<IPokemon>();
pokemon.MaxHealth.Returns(maxHp);
return pokemon;
}
/// <summary>
/// Helper to extract the heal amount from a substitute's received Heal calls, or null when Heal was
/// never called.
/// </summary>
private static uint? GetHealAmount(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Heal");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Bulbapedia (Generations V to VII): "The receiving Pokémon is now sent out at the end of the turn" —
/// using the move leaves a pending <see cref="LunarDanceEffect"/> on the user's side instead of healing
/// anything directly.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserInBattle_AddsLunarDanceEffectToUserSide()
{
// Arrange
var (script, move, _, sideVolatile) = CreateMoveSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName<LunarDanceEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "Lunar Dance causes the user to faint".
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserInBattle_UserFaints()
{
// Arrange
var (script, move, user, _) = CreateMoveSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Faint")).IsTrue();
}
/// <summary>
/// Bulbapedia: the dance benefits the Pokémon "sent out to take its place" — the pending effect is
/// bound to the position the user occupied, so a replacement entering that position is healed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserAtPosition_EffectTargetsUsersPosition()
{
// Arrange
var (script, move, _, sideVolatile) = CreateMoveSetup(1);
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
var effect = (LunarDanceEffect)sideVolatile.Get(new StringKey("lunar_dance"))!.Script!;
var replacement = CreateReplacement();
// Act
effect.OnSwitchIn(replacement, 1);
// Assert
await Assert.That(GetHealAmount(replacement)).IsNotNull();
}
/// <summary>
/// Technical test: outside of battle (no <see cref="IPokemon.BattleData"/>) the hook does nothing and
/// does not throw.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NoBattleData_DoesNothing()
{
// Arrange
var script = new LunarDance();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
move.User.Returns(user);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Faint")).IsFalse();
}
/// <summary>
/// Bulbapedia: "restores the HP ... of the Pokémon that is sent out to take its place" — the
/// replacement switching into the dancer's position is healed by its full maximum HP.
/// </summary>
[Test, Arguments(200u), Arguments(1u), Arguments(999u)]
public async Task OnSwitchIn_MatchingPosition_HealsReplacementToFullHp(uint maxHp)
{
// Arrange
var effect = new LunarDanceEffect(1);
var replacement = CreateReplacement(maxHp);
// Act
effect.OnSwitchIn(replacement, 1);
// Assert
await Assert.That(GetHealAmount(replacement)!.Value).IsEqualTo(maxHp);
}
/// <summary>
/// Bulbapedia: "restores the HP and PP of the Pokémon that is sent out" — unlike Healing Wish, Lunar
/// Dance also restores the replacement's PP.
/// </summary>
[Test]
public async Task OnSwitchIn_MatchingPosition_RestoresPP()
{
// Arrange
var effect = new LunarDanceEffect(1);
var replacement = CreateReplacement();
// Act
effect.OnSwitchIn(replacement, 1);
// Assert
await Assert.That(replacement.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "RestoreAllPP")).IsTrue();
}
/// <summary>
/// Bulbapedia: "removing all status conditions (except fainting)".
/// </summary>
[Test]
public async Task OnSwitchIn_MatchingPosition_CuresStatus()
{
// Arrange
var effect = new LunarDanceEffect(1);
var replacement = CreateReplacement();
// Act
effect.OnSwitchIn(replacement, 1);
// Assert
await Assert.That(replacement.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ClearStatus")).IsTrue();
}
/// <summary>
/// Bulbapedia (Generations V to VII): the dance is consumed by the Pokémon sent out at the position —
/// unlike Generation VIII onwards there is no check on the replacement's HP, status or PP, so the
/// effect is removed from the side after the first switch-in.
/// </summary>
[Test]
public async Task OnSwitchIn_MatchingPosition_EffectIsRemovedFromSide()
{
// Arrange
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet sideVolatile = new ScriptSet(side);
var effect = new LunarDanceEffect(1);
sideVolatile.Add(effect);
// Act
effect.OnSwitchIn(CreateReplacement(), 1);
// Assert
await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName<LunarDanceEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: the dance applies to the Pokémon sent out in the user's place — a Pokémon switching in
/// at a different position on the side is not restored and does not consume the effect.
/// </summary>
[Test]
public async Task OnSwitchIn_DifferentPosition_DoesNotRestoreAndEffectPersists()
{
// Arrange
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet sideVolatile = new ScriptSet(side);
var effect = new LunarDanceEffect(1);
sideVolatile.Add(effect);
var otherPokemon = CreateReplacement();
// Act
effect.OnSwitchIn(otherPokemon, 0);
// Assert
await Assert.That(GetHealAmount(otherPokemon)).IsNull();
await Assert.That(otherPokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ClearStatus")).IsFalse();
await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName<LunarDanceEffect>())).IsTrue();
}
}

View File

@@ -0,0 +1,176 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Battle;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="MagicRoom"/> move script and the <see cref="MagicRoomEffect"/> battle volatile it
/// applies. Gen VII Bulbapedia behavior: "Magic Room suppresses the effect of all items held by the Pokémon on
/// the field. This effect lasts for five turns." Using the move again while active removes the effect
/// immediately.
/// </summary>
public class MagicRoomTests
{
/// <summary>
/// Creates a fully mocked test setup for Magic Room tests. The battle gets a real
/// <see cref="ScriptSet"/> as its volatile set, so the applied effect can be inspected.
/// </summary>
private static (MagicRoom script, IExecutingMove move, IPokemon target, IBattle battle, IScriptSet battleVolatile)
CreateTestSetup()
{
var script = new MagicRoom();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var battle = Substitute.For<IBattle>();
battle.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var 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);
return (script, move, target, battle, battleVolatile);
}
/// <summary>
/// Bulbapedia: "Magic Room suppresses the effect of all items held by the Pokémon on the field."
/// The move script applies the <see cref="MagicRoomEffect"/> volatile, which implements the item
/// suppression, to the battle.
/// </summary>
[Test]
public async Task OnSecondaryEffect_MoveUsed_AddsMagicRoomEffectToBattleVolatile()
{
// Arrange
var (script, move, target, _, battleVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(battleVolatile.Get<MagicRoomEffect>()).IsNotNull();
}
/// <summary>
/// Bulbapedia: Magic Room affects "the Pokémon on the field"; a user without battle data has no field to
/// affect, so no effect is applied and no exception is thrown.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserHasNoBattleData_NoEffectAdded()
{
// Arrange
var (script, move, target, _, battleVolatile) = CreateTestSetup();
move.User.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(battleVolatile.Count).IsEqualTo(0);
}
/// <summary>
/// Bulbapedia: "If Magic Room is used while it is already in effect, it will end immediately."
/// Using the move a second time must remove the active effect from the battle instead of leaving it in
/// place.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UsedWhileActive_RemovesEffectImmediately()
{
// Arrange
var (script, move, target, _, battleVolatile) = CreateTestSetup();
// Act - second use while the effect is active
script.OnSecondaryEffect(move, target, 0);
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(battleVolatile.Contains("magic_room")).IsFalse();
}
// ----- MagicRoomEffect behavior (the volatile the move applies) -----
/// <summary>
/// Bulbapedia: "Magic Room suppresses the effect of all items held by the Pokémon on the field."
/// While the effect is active no Pokémon can consume its held item.
/// </summary>
[Test]
public async Task PreventHeldItemConsume_MagicRoomActive_ItemConsumptionPrevented()
{
// Arrange
var effect = new MagicRoomEffect();
var pokemon = Substitute.For<IPokemon>();
var item = Substitute.For<IItem>();
var prevented = false;
// Act
effect.PreventHeldItemConsume(pokemon, item, ref prevented);
// Assert
await Assert.That(prevented).IsTrue();
}
/// <summary>
/// Bulbapedia: "Magic Room suppresses the effect of all items held by the Pokémon on the field."
/// The effect suppresses all item battle trigger scripts while active.
/// </summary>
[Test]
public async Task OnBeforeAnyHookInvoked_NullCategoryList_SuppressesItemBattleTriggers()
{
// Arrange
var effect = new MagicRoomEffect();
List<ScriptCategory>? suppressedCategories = null;
// Act
effect.OnBeforeAnyHookInvoked(ref suppressedCategories);
// Assert
await Assert.That(suppressedCategories).IsNotNull();
await Assert.That(suppressedCategories!.Contains(ScriptCategory.ItemBattleTrigger)).IsTrue();
}
/// <summary>
/// Bulbapedia: "Magic Room suppresses the effect of all items held by the Pokémon on the field."
/// When other suppressions are already present, the item suppression is added to the existing list.
/// </summary>
[Test]
public async Task OnBeforeAnyHookInvoked_ExistingCategoryList_AddsItemBattleTriggerSuppression()
{
// Arrange
var effect = new MagicRoomEffect();
List<ScriptCategory>? suppressedCategories = [ScriptCategory.Weather];
// Act
effect.OnBeforeAnyHookInvoked(ref suppressedCategories);
// Assert
await Assert.That(suppressedCategories!.Contains(ScriptCategory.ItemBattleTrigger)).IsTrue();
await Assert.That(suppressedCategories!.Contains(ScriptCategory.Weather)).IsTrue();
}
/// <summary>
/// Bulbapedia: "This effect lasts for five turns." The turn Magic Room is used counts as the first turn,
/// so after five end-of-turn ticks the effect must have removed itself from the battle.
/// </summary>
[Test]
public async Task OnEndTurn_AfterFiveEndOfTurns_EffectHasEnded()
{
// Arrange
var (script, move, target, battle, battleVolatile) = CreateTestSetup();
script.OnSecondaryEffect(move, target, 0);
var effect = battleVolatile.Get<MagicRoomEffect>()!;
// Act - five end-of-turn ticks, the duration of the effect
for (var i = 0; i < 5; i++)
effect.OnEndTurn(battle, battle);
// Assert
await Assert.That(battleVolatile.Contains("magic_room")).IsFalse();
}
}

View File

@@ -0,0 +1,327 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
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.Pokemon;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="MagmaStorm"/> move script and its <see cref="MagmaStormEffect"/> volatile.
/// Gen VII Bulbapedia behavior: Magma Storm deals damage and binds the target. Generation IV: "A bound target
/// cannot switch out or flee, and takes damage ... at the end of each turn." Generation V: the trapping
/// duration changed to four to five turns. Generations VI and VII: the per-turn damage is increased to 1/8 of
/// the target's maximum HP, a Binding Band boosts it to 1/6, and "Ghost-type Pokémon cannot be trapped by
/// Magma Storm."
/// The trapping and end-of-turn damage themselves are implemented by the <see cref="MagmaStormEffect"/>
/// volatile; the move script determines the duration and damage fraction through the
/// <see cref="CustomTriggers.ModifyBind"/> custom trigger and applies the volatile to the target.
/// </summary>
public class MagmaStormTests
{
/// <summary>
/// Test helper script that modifies the bind duration/damage through the
/// <see cref="CustomTriggers.ModifyBind"/> custom trigger, the same way the Grip Claw and Binding Band
/// item scripts do.
/// </summary>
[Script(ScriptCategory.Pokemon, "test_modify_magma_storm_bind_trigger")]
private class ModifyBindTrigger : Script, IScriptCustomTrigger
{
private readonly int? _duration;
private readonly float? _damagePercent;
public ModifyBindTrigger(int? duration = null, float? damagePercent = null)
{
_duration = duration;
_damagePercent = damagePercent;
}
public void CustomTrigger(StringKey eventName, ICustomTriggerArgs args)
{
if (eventName != CustomTriggers.ModifyBind || args is not CustomTriggers.ModifyBindArgs bindArgs)
return;
if (_duration.HasValue)
bindArgs.Duration = _duration.Value;
if (_damagePercent.HasValue)
bindArgs.DamagePercent = _damagePercent.Value;
}
}
/// <summary>
/// Creates a fully mocked test setup for Magma Storm tests. The target gets a real
/// <see cref="ScriptSet"/> as its volatile set, so the applied effect can be inspected. The user's script
/// iterator is real but empty unless the test attaches scripts (such as the Grip Claw / Binding Band
/// stand-in), so the <see cref="CustomTriggers.ModifyBind"/> trigger pass runs.
/// </summary>
private static (MagmaStorm script, IExecutingMove move, IPokemon target, IScriptSet targetVolatile) CreateTestSetup(
params Script[] userScripts)
{
var script = new MagmaStorm();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var targetVolatile = new ScriptSet(target);
target.Volatile.Returns(targetVolatile);
var user = Substitute.For<IPokemon>();
var containers = userScripts.Select(IEnumerable<ScriptContainer> (s) => new ScriptContainer(s)).ToArray();
user.GetScripts().Returns(_ => new ScriptIterator(containers));
move.User.Returns(user);
return (script, move, target, targetVolatile);
}
/// <summary>
/// Bulbapedia: "Magma Storm deals damage and binds the target". The move script applies the
/// <see cref="MagmaStormEffect"/> volatile, which implements the trap, to the target that was hit.
/// </summary>
[Test]
public async Task OnSecondaryEffect_MoveHits_AddsMagmaStormEffectToTargetVolatile()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Get<MagmaStormEffect>()).IsNotNull();
}
/// <summary>
/// Technical test: hitting a target that is already trapped by Magma Storm does not add a second
/// <see cref="MagmaStormEffect"/>; the existing volatile is reused.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetAlreadyTrapped_DoesNotAddSecondEffect()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Count).IsEqualTo(1);
}
// ----- MagmaStormEffect behavior (the volatile the move applies) -----
/// <summary>
/// Creates a trapped Pokémon substitute with the given maximum HP, together with the
/// <see cref="MagmaStormEffect"/> that traps it.
/// </summary>
private static (MagmaStormEffect effect, IPokemon owner) CreateTrappedPokemon(uint maxHealth,
float percentOfMaxHealth = 1f / 8f)
{
var owner = Substitute.For<IPokemon>();
owner.MaxHealth.Returns(maxHealth);
owner.Types.Returns(new List<TypeIdentifier> { new(10, "fire") });
return (new MagmaStormEffect(owner, 5, percentOfMaxHealth), owner);
}
/// <summary>
/// Helper to extract the damage amount from a Pokémon's received Damage calls.
/// </summary>
private static uint? GetDamageAmount(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Runs end-of-turn handling on the effect repeatedly and counts how many turns dealt damage to the target.
/// </summary>
private static int CountEndTurnDamageTicks(MagmaStormEffect effect, IPokemon target, int maxTurns = 10)
{
var battle = Substitute.For<IBattle>();
for (var i = 0; i < maxTurns; i++)
effect.OnEndTurn(target, battle);
return target.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "Damage");
}
/// <summary>
/// Bulbapedia (Generations VI and VII): the damage taken by the bound target at the end of each turn is
/// increased from 1/16 to 1/8 of its maximum HP.
/// </summary>
[Test, Arguments(96u, 12u), Arguments(100u, 12u), Arguments(120u, 15u), Arguments(8u, 1u)]
public async Task OnEndTurn_TrappedPokemon_TakesOneEighthOfMaxHpAsDamage(uint maxHealth, uint expectedDamage)
{
// Arrange
var (effect, owner) = CreateTrappedPokemon(maxHealth);
// Act
effect.OnEndTurn(owner, Substitute.For<IBattle>());
// Assert
await Assert.That(GetDamageAmount(owner)!.Value).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia (Generation IV): the bound target "takes damage ... at the end of each turn"; the damage is
/// dealt once per turn, not more.
/// </summary>
[Test]
public async Task OnEndTurn_TrappedPokemon_TakesDamageOncePerTurn()
{
// Arrange
var (effect, owner) = CreateTrappedPokemon(96);
// Act
effect.OnEndTurn(owner, Substitute.For<IBattle>());
// Assert
var damageCalls = owner.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "Damage");
await Assert.That(damageCalls).IsEqualTo(1);
}
/// <summary>
/// Bulbapedia (Generation IV): the bound target "takes damage ... at the end of each turn"; this is
/// indirect damage, not move damage, so it uses <see cref="DamageSource.Misc"/>.
/// </summary>
[Test]
public async Task OnEndTurn_TrappedPokemon_DamageIsIndirect()
{
// Arrange
var (effect, owner) = CreateTrappedPokemon(100);
// Act
effect.OnEndTurn(owner, Substitute.For<IBattle>());
// Assert
var call = owner.ReceivedCalls().First(c => c.GetMethodInfo().Name == "Damage");
await Assert.That((DamageSource)call.GetArguments()[1]!).IsEqualTo(DamageSource.Misc);
}
/// <summary>
/// Bulbapedia (Generation IV): "A bound target cannot switch out".
/// </summary>
[Test]
public async Task PreventSelfSwitch_TrappedPokemon_CannotSwitchOut()
{
// Arrange
var (effect, owner) = CreateTrappedPokemon(100);
var choice = Substitute.For<ISwitchChoice>();
choice.User.Returns(owner);
var prevent = false;
// Act
effect.PreventSelfSwitch(choice, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia (Generation IV): "A bound target cannot switch out or flee".
/// </summary>
[Test]
public async Task PreventSelfRunAway_TrappedPokemon_CannotFlee()
{
// Arrange
var (effect, owner) = CreateTrappedPokemon(100);
var choice = Substitute.For<IFleeChoice>();
choice.User.Returns(owner);
var prevent = false;
// Act
effect.PreventSelfRunAway(choice, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia (Generations VI and VII): "Ghost-type Pokémon cannot be trapped by Magma Storm." A trapped
/// Ghost-type Pokémon must remain free to switch out.
/// </summary>
[Test]
public async Task PreventSelfSwitch_GhostTypeOwner_CanStillSwitchOut()
{
// Arrange
var (effect, owner) = CreateTrappedPokemon(100);
owner.Types.Returns(new List<TypeIdentifier> { new(8, "ghost") });
var choice = Substitute.For<ISwitchChoice>();
choice.User.Returns(owner);
var prevent = false;
// Act
effect.PreventSelfSwitch(choice, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia (Generations VI and VII): if the user is holding a Binding Band, the end-of-turn damage of
/// Magma Storm is increased to 1/6 of the target's maximum HP.
/// The damage change flows through the <see cref="CustomTriggers.ModifyBind"/> custom trigger; a script on
/// the user that sets the damage to 1/6 results in end-of-turn damage of 1/6 max HP.
/// </summary>
[Test, Arguments(120u, 20u), Arguments(48u, 8u)]
public async Task OnSecondaryEffect_DamageSetToOneSixthByTrigger_EndTurnDamageIsOneSixth(uint maxHealth,
uint expectedDamage)
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup(new ModifyBindTrigger(damagePercent: 1f / 6f));
target.MaxHealth.Returns(maxHealth);
// Act
script.OnSecondaryEffect(move, target, 0);
var effect = targetVolatile.Get<MagmaStormEffect>();
await Assert.That(effect).IsNotNull();
effect!.OnEndTurn(target, Substitute.For<IBattle>());
// Assert
await Assert.That(GetDamageAmount(target)!.Value).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia (Generation V onwards): if the user is holding a Grip Claw, the trapping lasts seven turns.
/// The duration change flows through the <see cref="CustomTriggers.ModifyBind"/> custom trigger; a script
/// on the user that sets the duration to 7 results in an effect lasting 7 turns.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DurationSetToSevenByTrigger_EffectLastsSevenTurns()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup(new ModifyBindTrigger(7));
target.MaxHealth.Returns(160u);
// Act
script.OnSecondaryEffect(move, target, 0);
var effect = targetVolatile.Get<MagmaStormEffect>();
await Assert.That(effect).IsNotNull();
var turnsWithDamage = CountEndTurnDamageTicks(effect!, target);
// Assert
await Assert.That(turnsWithDamage).IsEqualTo(7);
}
/// <summary>
/// Bulbapedia (Generation V onwards): the trapping lasts for four to five turns. After at most five
/// end-of-turn ticks the trap must have removed itself from the target.
/// </summary>
[Test]
public async Task OnEndTurn_AfterFiveEndOfTurns_TrapHasEnded()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
target.MaxHealth.Returns(80u);
script.OnSecondaryEffect(move, target, 0);
var effect = targetVolatile.Get<MagmaStormEffect>()!;
var battle = Substitute.For<IBattle>();
// Act - five end-of-turn ticks, the maximum duration of the trap
for (var i = 0; i < 5; i++)
effect.OnEndTurn(target, battle);
// Assert
await Assert.That(targetVolatile.Contains("magma_storm")).IsFalse();
}
}

View File

@@ -0,0 +1,206 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static;
using PkmnLib.Static.Moves;
using PkmnLib.Static.Species;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="MagnetRise"/> move script and its <see cref="MagnetRiseEffect"/> volatile.
/// Gen VII Bulbapedia behavior: "The user becomes immune to Ground-type attacks for five turns." "Magnet Rise
/// fails if the user is under the effects of Ingrain", "If the user is holding an Iron Ball, Magnet Rise can
/// still be used, but the user remains on the ground", and (Generation V onwards) "If used again, Magnet Rise
/// fails."
/// The Ground immunity itself is implemented by the <see cref="MagnetRiseEffect"/> volatile; the move script
/// is responsible for applying that volatile to the user.
/// </summary>
public class MagnetRiseTests
{
/// <summary>
/// Creates a fully mocked test setup for Magnet Rise tests. The user gets a real
/// <see cref="ScriptSet"/> as its volatile set, so the applied effect can be inspected.
/// </summary>
private static (MagnetRise script, IExecutingMove move, IPokemon target, IPokemon user, IScriptSet userVolatile,
IHitData hitData) CreateTestSetup()
{
var script = new MagnetRise();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
var user = Substitute.For<IPokemon>();
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
user.ActiveAbility.Returns((IAbility?)null);
var userVolatile = new ScriptSet(user);
user.Volatile.Returns(userVolatile);
move.User.Returns(user);
return (script, move, target, user, userVolatile, hitData);
}
/// <summary>
/// Helper that checks whether the hit was failed.
/// </summary>
private static bool HitWasFailed(IHitData hitData) =>
hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail");
/// <summary>
/// Bulbapedia: "The user becomes immune to Ground-type attacks for five turns."
/// The move script applies the <see cref="MagnetRiseEffect"/> volatile, which implements the immunity,
/// to the user, and the move does not fail.
/// </summary>
[Test]
public async Task OnSecondaryEffect_MoveUsed_AddsMagnetRiseEffectToUserVolatile()
{
// Arrange
var (script, move, target, _, userVolatile, hitData) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(userVolatile.Get<MagnetRiseEffect>()).IsNotNull();
await Assert.That(HitWasFailed(hitData)).IsFalse();
}
/// <summary>
/// Bulbapedia: "Magnet Rise fails if the user is under the effects of Ingrain". When the user has the
/// <see cref="IngrainEffect"/> volatile, the hit is failed and no <see cref="MagnetRiseEffect"/> is
/// applied.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserUnderIngrain_MoveFails()
{
// Arrange
var (script, move, target, user, userVolatile, hitData) = CreateTestSetup();
userVolatile.Add(new IngrainEffect(user));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(HitWasFailed(hitData)).IsTrue();
await Assert.That(userVolatile.Contains("magnet_rise")).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation V onwards): "If used again, Magnet Rise fails." Using the move while its
/// effect is already active must fail the hit.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UsedWhileAlreadyActive_MoveFails()
{
// Arrange
var (script, move, target, _, _, hitData) = CreateTestSetup();
// Act - second use while the effect is active
script.OnSecondaryEffect(move, target, 0);
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(HitWasFailed(hitData)).IsTrue();
}
// ----- MagnetRiseEffect behavior (the volatile the move applies) -----
/// <summary>
/// Creates an incoming executing move of the given type, aimed at the given target.
/// </summary>
private static IExecutingMove CreateIncomingMove(string moveType)
{
var move = Substitute.For<IExecutingMove>();
var moveData = Substitute.For<IMoveData>();
moveData.MoveType.Returns(new TypeIdentifier(5, moveType));
move.UseMove.Returns(moveData);
return move;
}
/// <summary>
/// Bulbapedia: "The user becomes immune to Ground-type attacks for five turns." While the effect is
/// active, the effectiveness of incoming Ground-type moves against the user becomes 0.
/// </summary>
[Test]
public async Task ChangeEffectiveness_IncomingGroundMove_UserIsImmune()
{
// Arrange
var effect = new MagnetRiseEffect();
var incomingMove = CreateIncomingMove("ground");
var owner = Substitute.For<IPokemon>();
var effectiveness = 1.0f;
// Act
effect.ChangeEffectiveness(incomingMove, owner, 0, ref effectiveness);
// Assert
await Assert.That(effectiveness).IsEqualTo(0.0f);
}
/// <summary>
/// Bulbapedia: the user only becomes "immune to Ground-type attacks"; moves of other types are
/// unaffected by Magnet Rise.
/// </summary>
[Test]
public async Task ChangeEffectiveness_IncomingNonGroundMove_EffectivenessUnchanged()
{
// Arrange
var effect = new MagnetRiseEffect();
var incomingMove = CreateIncomingMove("rock");
var owner = Substitute.For<IPokemon>();
var effectiveness = 2.0f;
// Act
effect.ChangeEffectiveness(incomingMove, owner, 0, ref effectiveness);
// Assert
await Assert.That(effectiveness).IsEqualTo(2.0f);
}
/// <summary>
/// Bulbapedia: "If the user is holding an Iron Ball, Magnet Rise can still be used, but the user remains
/// on the ground until the item is removed from the user." A user holding an Iron Ball is not immune to
/// Ground-type moves despite the active effect.
/// </summary>
[Test]
public async Task ChangeEffectiveness_OwnerHoldingIronBall_GroundMovesStillHit()
{
// Arrange
var effect = new MagnetRiseEffect();
var incomingMove = CreateIncomingMove("ground");
var owner = Substitute.For<IPokemon>();
owner.HasHeldItem("iron_ball").Returns(true);
incomingMove.User.Returns(owner);
var effectiveness = 1.0f;
// Act
effect.ChangeEffectiveness(incomingMove, owner, 0, ref effectiveness);
// Assert
await Assert.That(effectiveness).IsEqualTo(1.0f);
}
/// <summary>
/// Bulbapedia: "The user becomes immune to Ground-type attacks for five turns." The turn Magnet Rise is
/// used counts as the first turn, so after five end-of-turn ticks the effect must have removed itself
/// from the user.
/// </summary>
[Test]
public async Task OnEndTurn_AfterFiveEndOfTurns_EffectHasEnded()
{
// Arrange
var (script, move, target, user, userVolatile, _) = CreateTestSetup();
script.OnSecondaryEffect(move, target, 0);
var effect = userVolatile.Get<MagnetRiseEffect>()!;
var battle = Substitute.For<IBattle>();
// Act - five end-of-turn ticks, the duration of the effect
for (var i = 0; i < 5; i++)
effect.OnEndTurn(user, battle);
// Assert
await Assert.That(userVolatile.Contains("magnet_rise")).IsFalse();
}
}

View File

@@ -0,0 +1,244 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Species;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="MagneticFlux"/> move script.
/// Gen VII Bulbapedia behavior: "Magnetic Flux raises the Defense and Special Defense stats of allied
/// Pokémon (including the user) with the Ability Plus or Minus by one stage each."
/// </summary>
public class MagneticFluxTests
{
private static (MagneticFlux script, IExecutingMove move, IPokemon user) CreateTestSetup(
params IPokemon?[] sidePokemon)
{
var script = new MagneticFlux();
var side = Substitute.For<IBattleSide>();
side.Pokemon.Returns(sidePokemon);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.BattleSide.Returns(side);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
return (script, move, user);
}
private static IPokemon CreatePokemonWithAbility(string? abilityName)
{
var pokemon = Substitute.For<IPokemon>();
if (abilityName is null)
{
pokemon.ActiveAbility.Returns((IAbility?)null);
}
else
{
var ability = Substitute.For<IAbility>();
ability.Name.Returns(new StringKey(abilityName));
pokemon.ActiveAbility.Returns(ability);
}
return pokemon;
}
/// <summary>
/// Helper to extract the arguments of all received ChangeStatBoost calls. Received-call inspection is
/// used instead of NSubstitute argument matchers because the trailing <c>EventBatchId</c> parameter
/// cannot be bound by <c>Arg.Any</c> (its parameterless constructor initializes a fresh id, so it
/// never equals the matcher's default value).
/// </summary>
private static List<object?[]> GetStatBoostCalls(IPokemon pokemon) =>
pokemon.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost").Select(c => c.GetArguments())
.ToList();
/// <summary>
/// Bulbapedia: "Magnetic Flux raises the Defense and Special Defense stats of allied Pokémon
/// (including the user) with the Ability Plus or Minus by one stage each." An ally with Plus has both
/// its Defense and its Special Defense raised by one stage.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AllyWithPlus_DefenseAndSpecialDefenseRaisedByOneStage()
{
// Arrange
var ally = CreatePokemonWithAbility("plus");
var (script, move, _) = CreateTestSetup(ally);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
var calls = GetStatBoostCalls(ally);
await Assert.That(calls.Count).IsEqualTo(2);
var boostedStats = calls.Select(args => (Statistic)args[0]!).ToList();
await Assert.That(boostedStats.Contains(Statistic.Defense)).IsTrue();
await Assert.That(boostedStats.Contains(Statistic.SpecialDefense)).IsTrue();
await Assert.That(calls.All(args => (sbyte)args[1]! == 1)).IsTrue();
}
/// <summary>
/// Bulbapedia: the boost applies to allied Pokémon "with the Ability Plus or Minus" — an ally with
/// Minus is affected just like one with Plus.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AllyWithMinus_DefenseAndSpecialDefenseRaisedByOneStage()
{
// Arrange
var ally = CreatePokemonWithAbility("minus");
var (script, move, _) = CreateTestSetup(ally);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
var calls = GetStatBoostCalls(ally);
await Assert.That(calls.Count).IsEqualTo(2);
var boostedStats = calls.Select(args => (Statistic)args[0]!).ToList();
await Assert.That(boostedStats.Contains(Statistic.Defense)).IsTrue();
await Assert.That(boostedStats.Contains(Statistic.SpecialDefense)).IsTrue();
await Assert.That(calls.All(args => (sbyte)args[1]! == 1)).IsTrue();
}
/// <summary>
/// Bulbapedia: only allied Pokémon "with the Ability Plus or Minus" are affected — an ally with a
/// different ability does not have its stats raised.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AllyWithOtherAbility_StatsNotRaised()
{
// Arrange
var ally = CreatePokemonWithAbility("levitate");
var (script, move, _) = CreateTestSetup(ally);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(GetStatBoostCalls(ally).Count).IsEqualTo(0);
}
/// <summary>
/// Bulbapedia: only allied Pokémon "with the Ability Plus or Minus" are affected — an ally without an
/// active ability does not have its stats raised, and the script does not throw.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AllyWithoutActiveAbility_StatsNotRaised()
{
// Arrange
var ally = CreatePokemonWithAbility(null);
var (script, move, _) = CreateTestSetup(ally);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(GetStatBoostCalls(ally).Count).IsEqualTo(0);
}
/// <summary>
/// Bulbapedia: the boost applies to allied Pokémon "(including the user)" — when the user itself has
/// Plus, its own Defense and Special Defense are raised, and the boost counts as self-inflicted.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserWithPlus_BoostIsSelfInflicted()
{
// Arrange
var (script, move, user) = CreateTestSetup();
var ability = Substitute.For<IAbility>();
ability.Name.Returns(new StringKey("plus"));
user.ActiveAbility.Returns(ability);
user.BattleData!.BattleSide.Pokemon.Returns(new IPokemon?[] { user });
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
var calls = GetStatBoostCalls(user);
await Assert.That(calls.Count).IsEqualTo(2);
await Assert.That(calls.All(args => (bool)args[2]!)).IsTrue(); // self-inflicted for the user itself
}
/// <summary>
/// Bulbapedia: "Magnetic Flux raises the Defense and Special Defense stats of allied Pokémon". A boost
/// applied to an ally other than the user is not self-inflicted.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AllyWithPlus_BoostIsNotSelfInflicted()
{
// Arrange
var ally = CreatePokemonWithAbility("plus");
var (script, move, _) = CreateTestSetup(ally);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
var calls = GetStatBoostCalls(ally);
await Assert.That(calls.Count).IsEqualTo(2);
await Assert.That(calls.All(args => (bool)args[2]!)).IsFalse(); // not self-inflicted
}
/// <summary>
/// Bulbapedia: the boost applies to "allied Pokémon" with Plus or Minus — when multiple eligible
/// allies are on the user's side, every one of them is raised.
/// </summary>
[Test]
public async Task OnSecondaryEffect_MultipleEligibleAllies_AllAreRaised()
{
// Arrange
var plusAlly = CreatePokemonWithAbility("plus");
var minusAlly = CreatePokemonWithAbility("minus");
var (script, move, _) = CreateTestSetup(plusAlly, minusAlly);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(GetStatBoostCalls(plusAlly).Count).IsEqualTo(2);
await Assert.That(GetStatBoostCalls(minusAlly).Count).IsEqualTo(2);
}
/// <summary>
/// Technical test: empty (null) slots on the user's side are skipped without throwing, and eligible
/// allies in other slots are still raised.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EmptyPokemonSlot_IsSkipped()
{
// Arrange
var ally = CreatePokemonWithAbility("plus");
var (script, move, _) = CreateTestSetup(null, ally);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(GetStatBoostCalls(ally).Count).IsEqualTo(2);
}
/// <summary>
/// Technical test: when the user has no <see cref="IPokemon.BattleData"/> (it is not in a battle),
/// the script does nothing instead of throwing.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NullBattleData_DoesNothing()
{
// Arrange
var script = new MagneticFlux();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
// Act & Assert - should not throw
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
await Assert.That(GetStatBoostCalls(user).Count).IsEqualTo(0);
}
}

View File

@@ -0,0 +1,182 @@
using PkmnLib.Dynamic.Events;
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Magnitude"/> move script.
/// Gen VII Bulbapedia behavior: "Magnitude's power varies based on a randomly selected 'magnitude' value
/// ranging from 4 to 10, with 4 having the least power and 10 having the greatest."
/// The doubling of damage against a Pokémon in the semi-invulnerable turn of Dig and the halving under
/// Grassy Terrain are handled outside this script and are not tested here.
/// </summary>
public class MagnitudeTests
{
private static (Magnitude script, IExecutingMove move, IPokemon target, IPokemon user, EventHook eventHook)
CreateTestSetup(int randomValue)
{
var script = new Magnitude();
var random = Substitute.For<IBattleRandom>();
random.GetInt(0, 100).Returns(randomValue);
var eventHook = new EventHook();
var battle = Substitute.For<IBattle>();
battle.Random.Returns(random);
battle.EventHook.Returns(eventHook);
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);
var target = Substitute.For<IPokemon>();
return (script, move, target, user, eventHook);
}
/// <summary>
/// Bulbapedia magnitude→power table: 4→10, 5→30, 6→50, 7→70, 8→90, 9→110, 10→150.
/// Each row exercises a boundary of a random-percentage bracket, verifying both edges of every
/// magnitude's probability range (5%, 10%, 20%, 30%, 20%, 10%, 5%).
/// </summary>
[Test, Arguments(0, 10), Arguments(4, 10), Arguments(5, 30), Arguments(14, 30), Arguments(15, 50),
Arguments(34, 50), Arguments(35, 70), Arguments(64, 70), Arguments(65, 90), Arguments(84, 90), Arguments(85, 110),
Arguments(94, 110), Arguments(95, 150), Arguments(99, 150)]
// magnitude 4 bracket start
// magnitude 4 bracket end
// magnitude 5 bracket start
// magnitude 5 bracket end
// magnitude 6 bracket start
// magnitude 6 bracket end
// magnitude 7 bracket start
// magnitude 7 bracket end
// magnitude 8 bracket start
// magnitude 8 bracket end
// magnitude 9 bracket start
// magnitude 9 bracket end
// magnitude 10 bracket start
// magnitude 10 bracket end
public async Task ChangeBasePower_RandomRoll_MapsToExpectedBasePower(int randomValue, int expectedPower)
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup(randomValue);
ushort basePower = 1;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)expectedPower);
}
/// <summary>
/// Bulbapedia probability table: magnitude 4 has 5% probability, 5 has 10%, 6 has 20%, 7 has 30%,
/// 8 has 20%, 9 has 10% and 10 has 5%. Rolling every possible random percentage (099) exactly once
/// must produce each power in exactly that proportion.
/// </summary>
[Test]
public async Task ChangeBasePower_AllRandomRolls_MatchBulbapediaProbabilities()
{
// Arrange
var powerCounts = new Dictionary<ushort, int>();
// Act - roll every possible random percentage once
for (var roll = 0; roll < 100; roll++)
{
var (script, move, target, _, _) = CreateTestSetup(roll);
ushort basePower = 1;
script.ChangeBasePower(move, target, 0, ref basePower);
powerCounts[basePower] = powerCounts.GetValueOrDefault(basePower) + 1;
}
// Assert - counts out of 100 equal the Bulbapedia percentages
await Assert.That(powerCounts.GetValueOrDefault((ushort)10)).IsEqualTo(5);
await Assert.That(powerCounts.GetValueOrDefault((ushort)30)).IsEqualTo(10);
await Assert.That(powerCounts.GetValueOrDefault((ushort)50)).IsEqualTo(20);
await Assert.That(powerCounts.GetValueOrDefault((ushort)70)).IsEqualTo(30);
await Assert.That(powerCounts.GetValueOrDefault((ushort)90)).IsEqualTo(20);
await Assert.That(powerCounts.GetValueOrDefault((ushort)110)).IsEqualTo(10);
await Assert.That(powerCounts.GetValueOrDefault((ushort)150)).IsEqualTo(5);
}
/// <summary>
/// Bulbapedia: the power is based on "a randomly selected 'magnitude' value ranging from 4 to 10" —
/// the selected magnitude is announced through a <see cref="DialogEvent"/> so the front-end can show
/// it (in the games: "Magnitude &lt;value&gt;!").
/// </summary>
[Test, Arguments(0, 4), Arguments(5, 5), Arguments(15, 6), Arguments(35, 7), Arguments(65, 8), Arguments(85, 9),
Arguments(95, 10)]
public async Task ChangeBasePower_RandomRoll_FiresDialogEventWithSelectedMagnitude(int randomValue,
int expectedMagnitude)
{
// Arrange
var (script, move, target, _, eventHook) = CreateTestSetup(randomValue);
DialogEvent? capturedEvent = null;
eventHook.Handler += (_, args) =>
{
if (args is DialogEvent dialogEvent)
capturedEvent = dialogEvent;
};
ushort basePower = 1;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(capturedEvent).IsNotNull();
await Assert.That(capturedEvent!.Message).IsEqualTo("magnitude");
await Assert.That((int)capturedEvent.Parameters!["magnitude"]).IsEqualTo(expectedMagnitude);
}
/// <summary>
/// Bulbapedia: the magnitude announcement concerns the move's user and target — the fired
/// <see cref="DialogEvent"/> carries both so the front-end can format its message.
/// </summary>
[Test]
public async Task ChangeBasePower_RandomRoll_DialogEventContainsUserAndTarget()
{
// Arrange
var (script, move, target, user, eventHook) = CreateTestSetup(50);
DialogEvent? capturedEvent = null;
eventHook.Handler += (_, args) =>
{
if (args is DialogEvent dialogEvent)
capturedEvent = dialogEvent;
};
ushort basePower = 1;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(capturedEvent).IsNotNull();
await Assert.That((IPokemon)capturedEvent!.Parameters!["user"]).IsEqualTo(user);
await Assert.That((IPokemon)capturedEvent.Parameters!["target"]).IsEqualTo(target);
}
/// <summary>
/// Technical test: when the user has no <see cref="IPokemon.BattleData"/> (it is not in a battle),
/// no random roll can be made and the base power is left unchanged.
/// </summary>
[Test]
public async Task ChangeBasePower_NullBattleData_BasePowerUnchanged()
{
// Arrange
var script = new Magnitude();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
ushort basePower = 1;
// Act
script.ChangeBasePower(move, Substitute.For<IPokemon>(), 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)1);
}
}

View File

@@ -0,0 +1,202 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
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="MatBlock"/> move script and the <see cref="MatBlockEffect"/> it applies.
/// Gen VII Bulbapedia behavior: "Mat Block protects all Pokémon on the user's side of the field from any
/// physical or special moves for that turn."
/// </summary>
public class MatBlockTests
{
private static (MatBlock script, IExecutingMove move, IPokemon target, IHitData hitData, IScriptSet sideVolatile)
CreateTestSetup(uint switchInTurn, uint currentTurn)
{
var script = new MatBlock();
var side = Substitute.For<IBattleSide>();
// Use a real script set so the side script added by Mat Block can be inspected afterwards.
var sideVolatile = new ScriptSet(side);
side.VolatileScripts.Returns(sideVolatile);
side.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var battle = Substitute.For<IBattle>();
battle.CurrentTurnNumber.Returns(currentTurn);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
battleData.BattleSide.Returns(side);
battleData.SwitchInTurn.Returns(switchInTurn);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
return (script, move, target, hitData, sideVolatile);
}
private static IExecutingMove CreateExecutingMoveOfCategory(MoveCategory category)
{
var useMove = Substitute.For<IMoveData>();
useMove.Category.Returns(category);
var move = Substitute.For<IExecutingMove>();
move.UseMove.Returns(useMove);
return move;
}
/// <summary>
/// Bulbapedia: "Mat Block protects all Pokémon on the user's side of the field" — using the move on
/// the user's first turn on the field attaches the <see cref="MatBlockEffect"/> to the user's side.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UsedOnSwitchInTurn_AddsMatBlockEffectToUsersSide()
{
// Arrange
var (script, move, target, _, sideVolatile) = CreateTestSetup(1, 1);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName<MatBlockEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: Mat Block only fails "if not used on the first turn after the Pokémon enters the
/// field" — on the user's first turn on the field the hit is not marked as failed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UsedOnSwitchInTurn_HitDoesNotFail()
{
// Arrange
var (script, move, target, hitData, _) = CreateTestSetup(1, 1);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsFalse();
}
/// <summary>
/// Bulbapedia: "Mat Block will fail if not used on the first turn after the Pokémon enters the field;
/// the Pokémon must be switched out and back in to be able to use Mat Block again."
/// </summary>
[Test]
public async Task OnSecondaryEffect_NotFirstTurnOnField_HitFails()
{
// Arrange
var (script, move, target, hitData, _) = CreateTestSetup(1, 2);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue();
}
/// <summary>
/// Bulbapedia: "Mat Block will fail if not used on the first turn after the Pokémon enters the field"
/// — when it fails, no protection is set up on the user's side.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NotFirstTurnOnField_NoEffectAdded()
{
// Arrange
var (script, move, target, _, sideVolatile) = CreateTestSetup(1, 2);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName<MatBlockEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: the protection covers "any physical or special moves" — the
/// <see cref="MatBlockEffect"/> blocks an incoming damaging hit of either category.
/// </summary>
[Test, Arguments(MoveCategory.Physical), Arguments(MoveCategory.Special)]
public async Task MatBlockEffect_DamagingMove_IsBlocked(MoveCategory category)
{
// Arrange
var effect = new MatBlockEffect();
var incomingMove = CreateExecutingMoveOfCategory(category);
var block = false;
// Act
effect.BlockIncomingHit(incomingMove, Substitute.For<IPokemon>(), 0, ref block);
// Assert
await Assert.That(block).IsTrue();
}
/// <summary>
/// Bulbapedia: Mat Block protects "from any physical or special moves" — unlike Protect it does not
/// stop status moves, so an incoming status move is not blocked.
/// </summary>
[Test]
public async Task MatBlockEffect_StatusMove_IsNotBlocked()
{
// Arrange
var effect = new MatBlockEffect();
var incomingMove = CreateExecutingMoveOfCategory(MoveCategory.Status);
var block = false;
// Act
effect.BlockIncomingHit(incomingMove, Substitute.For<IPokemon>(), 0, ref block);
// Assert
await Assert.That(block).IsFalse();
}
/// <summary>
/// Bulbapedia: the protection lasts "for that turn" — at the end of the turn the
/// <see cref="MatBlockEffect"/> removes itself from the side.
/// </summary>
[Test]
public async Task MatBlockEffect_OnEndTurn_EffectExpires()
{
// Arrange
var effect = new MatBlockEffect();
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
IScriptSet sideVolatile = new ScriptSet(side);
sideVolatile.Add(effect);
// Act
effect.OnEndTurn(side, Substitute.For<IBattle>());
// Assert
await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName<MatBlockEffect>())).IsFalse();
}
/// <summary>
/// Technical test: outside of battle (no battle data) no protection can be raised and nothing
/// happens.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NullBattleData_DoesNothing()
{
// Arrange
var (script, move, target, hitData, sideVolatile) = CreateTestSetup(1, 1);
move.User.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - no effect is added and the hit is not failed
await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName<MatBlockEffect>())).IsFalse();
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsFalse();
}
}

View File

@@ -0,0 +1,294 @@
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.MoveVolatile;
using PkmnLib.Static.Moves;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="MeFirst"/> move script and its <see cref="MeFirstPowerBoost"/> volatile.
/// Gen VII Bulbapedia behavior: "If the target has not made its move this turn, but has selected a
/// damage-dealing move, Me First copies that move preemptively and increases the power by 50%."
/// "Me First will fail if the target selected a non-damaging move, or if the target already executed its
/// move this turn."
/// </summary>
public class MeFirstTests
{
/// <summary>
/// Creates a substitute move choice for the target, queued with the given move name and category.
/// </summary>
private static IMoveChoice CreateTargetMoveChoice(IPokemon target, string moveName,
MoveCategory category = MoveCategory.Physical)
{
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new StringKey(moveName));
moveData.Category.Returns(category);
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
var moveChoice = Substitute.For<IMoveChoice>();
moveChoice.User.Returns(target);
moveChoice.ChosenMove.Returns(learnedMove);
return moveChoice;
}
/// <summary>
/// Creates a fully mocked test setup for Me First tests. The user's move choice targets side 0,
/// position 0, where <paramref name="target"/> is standing (or nothing, when null). The battle's
/// choice queue holds the given remaining choices for this turn.
/// </summary>
private static (MeFirst script, IMoveChoice choice) CreateTestSetup(IPokemon? target, ITurnChoice[] queuedChoices,
bool targetChoiceIsReplacement = false)
{
var script = new MeFirst();
var side = Substitute.For<IBattleSide>();
side.Pokemon.Returns(new List<IPokemon?> { target });
var miscLibrary = Substitute.For<IMiscLibrary>();
miscLibrary.IsReplacementChoice(Arg.Any<ITurnChoice>()).Returns(targetChoiceIsReplacement);
var library = Substitute.For<IDynamicLibrary>();
library.MiscLibrary.Returns(miscLibrary);
var battle = Substitute.For<IBattle>();
battle.Sides.Returns(new[] { side });
battle.ChoiceQueue.Returns(new BattleChoiceQueue(queuedChoices));
battle.Library.Returns(library);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
choice.TargetSide.Returns((byte)0);
choice.TargetPosition.Returns((byte)0);
// Use a real script set so the volatile script added by Me First can be inspected afterwards.
choice.Volatile.Returns(new ScriptSet(choice));
choice.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
return (script, choice);
}
/// <summary>
/// Bulbapedia: "If the target has not made its move this turn, but has selected a damage-dealing move,
/// Me First copies that move preemptively".
/// The executed move is replaced by the move the target has queued.
/// </summary>
[Test]
public async Task ChangeMove_TargetSelectedDamagingMove_ChangesMoveToTargetsMove()
{
// Arrange
var target = Substitute.For<IPokemon>();
var targetChoice = CreateTargetMoveChoice(target, "tackle");
var (script, choice) = CreateTestSetup(target, [targetChoice]);
StringKey moveName = "me_first";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("tackle"));
choice.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: "Me First copies that move preemptively and increases the power by 50%."
/// Copying the move attaches the <see cref="MeFirstPowerBoost"/> volatile to the move choice, which is
/// responsible for the power increase.
/// </summary>
[Test]
public async Task ChangeMove_TargetSelectedDamagingMove_AddsPowerBoostVolatile()
{
// Arrange
var target = Substitute.For<IPokemon>();
var targetChoice = CreateTargetMoveChoice(target, "tackle");
var (script, choice) = CreateTestSetup(target, [targetChoice]);
StringKey moveName = "me_first";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(choice.Volatile.Contains(ScriptUtils.ResolveName<MeFirstPowerBoost>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "Me First copies that move preemptively and increases the power by 50%."
/// The <see cref="MeFirstPowerBoost"/> volatile multiplies the copied move's base power by 1.5,
/// truncating fractions and capping at <see cref="ushort.MaxValue"/>.
/// </summary>
[Test, Arguments((ushort)100, (ushort)150), Arguments((ushort)60, (ushort)90), Arguments((ushort)5, (ushort)7),
Arguments((ushort)85, (ushort)127), Arguments(ushort.MaxValue, ushort.MaxValue)]
public async Task ChangeBasePower_MeFirstPowerBoost_IncreasesPowerByFiftyPercent(ushort basePower, ushort expected)
{
// Arrange
var boost = new MeFirstPowerBoost();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
// Act
boost.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expected);
}
/// <summary>
/// Bulbapedia: "Me First will fail if the target selected a non-damaging move, or if the target already
/// executed its move this turn."
/// The target's choice is no longer in the queue, so the move fails.
/// </summary>
[Test]
public async Task ChangeMove_TargetAlreadyExecutedMove_Fails()
{
// Arrange
var target = Substitute.For<IPokemon>();
var (script, choice) = CreateTestSetup(target, []);
StringKey moveName = "me_first";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
choice.Received(1).Fail();
await Assert.That(moveName).IsEqualTo(new StringKey("me_first"));
}
/// <summary>
/// Bulbapedia: "Unlike other moves, if the target of Me First is a vacant slot or a Pokémon that already
/// fainted before the Me First user's turn, Me First will fail instead of being redirected to an adjacent
/// non-fainted opponent."
/// </summary>
[Test]
public async Task ChangeMove_TargetSlotIsVacant_Fails()
{
// Arrange
var (script, choice) = CreateTestSetup(null, []);
StringKey moveName = "me_first";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
choice.Received(1).Fail();
await Assert.That(moveName).IsEqualTo(new StringKey("me_first"));
}
/// <summary>
/// Bulbapedia: Me First only copies a move the target "has selected". A target that chose to switch out
/// instead of using a move has no selected move to copy, so Me First fails.
/// </summary>
[Test]
public async Task ChangeMove_TargetChoiceIsNotAMoveChoice_Fails()
{
// Arrange
var target = Substitute.For<IPokemon>();
var switchChoice = Substitute.For<ISwitchChoice>();
switchChoice.User.Returns(target);
var (script, choice) = CreateTestSetup(target, [switchChoice]);
StringKey moveName = "me_first";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
choice.Received(1).Fail();
await Assert.That(moveName).IsEqualTo(new StringKey("me_first"));
}
/// <summary>
/// Bulbapedia: "Me First will fail if the target chose a Z-Move or any of the following moves."
/// Struggle is one of those moves; a replacement choice (the forced choice used when a Pokémon cannot
/// select a move, usually Struggle) cannot be copied.
/// </summary>
[Test]
public async Task ChangeMove_TargetChoiceIsReplacementChoice_Fails()
{
// Arrange
var target = Substitute.For<IPokemon>();
var targetChoice = CreateTargetMoveChoice(target, "struggle");
var (script, choice) = CreateTestSetup(target, [targetChoice], true);
StringKey moveName = "me_first";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
choice.Received(1).Fail();
await Assert.That(moveName).IsEqualTo(new StringKey("me_first"));
}
/// <summary>
/// Technical test: outside of battle (no battle data) the script returns without throwing and without
/// changing the move.
/// </summary>
[Test]
public async Task ChangeMove_NoBattleData_DoesNotChangeMove()
{
// Arrange
var script = new MeFirst();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
StringKey moveName = "me_first";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("me_first"));
}
/// <summary>
/// Bulbapedia: "Me First will fail if the target selected a non-damaging move, or if the target already
/// executed its move this turn."
/// A queued status move must make Me First fail instead of being copied.
/// </summary>
[Test]
public async Task ChangeMove_TargetSelectedNonDamagingMove_Fails()
{
// Arrange
var target = Substitute.For<IPokemon>();
var targetChoice = CreateTargetMoveChoice(target, "swords_dance", MoveCategory.Status);
var (script, choice) = CreateTestSetup(target, [targetChoice]);
StringKey moveName = "me_first";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
choice.Received(1).Fail();
await Assert.That(moveName).IsEqualTo(new StringKey("me_first"));
}
/// <summary>
/// Bulbapedia: "Me First will fail if the target chose a Z-Move or any of the following moves." In
/// Generation VII the listed moves are Beak Blast, Belch, Chatter, Counter, Covet, Focus Punch, Me First,
/// Metal Burst, Mirror Coat, Shell Trap, Struggle and Thief.
/// </summary>
[Test, Arguments("beak_blast"), Arguments("belch"), Arguments("chatter"), Arguments("counter"), Arguments("covet"),
Arguments("focus_punch"), Arguments("metal_burst"), Arguments("mirror_coat"), Arguments("shell_trap"),
Arguments("thief")]
public async Task ChangeMove_TargetSelectedUncopyableMove_Fails(string targetMove)
{
// Arrange
var target = Substitute.For<IPokemon>();
var targetChoice = CreateTargetMoveChoice(target, targetMove);
var (script, choice) = CreateTestSetup(target, [targetChoice]);
StringKey moveName = "me_first";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
choice.Received(1).Fail();
await Assert.That(moveName).IsEqualTo(new StringKey("me_first"));
}
}

View File

@@ -0,0 +1,188 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="MeanLook"/> move script.
/// Gen VII Bulbapedia behavior: "Mean Look prevents the target from switching out or fleeing (including via
/// Teleport). [...] The effect only applies as long as the Pokémon that used it remains in battle."
/// From Generation VI onward, Ghost-type Pokémon are immune to Mean Look.
/// </summary>
public class MeanLookTests
{
private static (MeanLook script, IExecutingMove move, IPokemon target, ScriptSet targetVolatile, ScriptSet
userVolatile) CreateTestSetup()
{
var script = new MeanLook();
var move = Substitute.For<IExecutingMove>();
// Use real script sets so the volatile scripts added by Mean Look can be inspected afterwards.
var user = Substitute.For<IPokemon>();
var userVolatile = new ScriptSet(user);
user.Volatile.Returns(userVolatile);
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
var targetVolatile = new ScriptSet(target);
target.Volatile.Returns(targetVolatile);
target.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
move.GetHitData(target, 0).Returns(Substitute.For<IHitData>());
return (script, move, target, targetVolatile, userVolatile);
}
/// <summary>
/// Bulbapedia: "Mean Look prevents the target from switching out or fleeing (including via Teleport)."
/// Hitting the target attaches the <see cref="MeanLookEffectTarget"/> volatile script to it.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Hit_AddsMeanLookEffectToTarget()
{
// Arrange
var (script, move, target, targetVolatile, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<MeanLookEffectTarget>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "Mean Look prevents the target from switching out".
/// The <see cref="MeanLookEffectTarget"/> applied by the move prevents the target's switch choices
/// through <see cref="MeanLookEffectTarget.PreventSelfSwitch"/>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AppliedEffect_PreventsTargetFromSwitchingOut()
{
// Arrange
var (script, move, target, targetVolatile, _) = CreateTestSetup();
script.OnSecondaryEffect(move, target, 0);
await Assert.That(targetVolatile.TryGet<MeanLookEffectTarget>(out var effect)).IsTrue();
// Act
var prevent = false;
effect!.PreventSelfSwitch(Substitute.For<ISwitchChoice>(), ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "Mean Look prevents the target from [...] fleeing (including via Teleport)."
/// The <see cref="MeanLookEffectTarget"/> applied by the move prevents the target's flee choices
/// through <see cref="MeanLookEffectTarget.PreventSelfRunAway"/>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AppliedEffect_PreventsTargetFromFleeing()
{
// Arrange
var (script, move, target, targetVolatile, _) = CreateTestSetup();
script.OnSecondaryEffect(move, target, 0);
await Assert.That(targetVolatile.TryGet<MeanLookEffectTarget>(out var effect)).IsTrue();
// Act
var prevent = false;
effect!.PreventSelfRunAway(Substitute.For<IFleeChoice>(), ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "The effect only applies as long as the Pokémon that used it remains in battle."
/// The user is given a linked <see cref="MeanLookEffectUser"/> volatile that ties the trap to the
/// user's presence in battle.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Hit_AddsLinkedEffectToUser()
{
// Arrange
var (script, move, target, _, userVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<MeanLookEffectUser>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "The effect only applies as long as the Pokémon that used it remains in battle."
/// When the user's linked <see cref="MeanLookEffectUser"/> volatile is removed (as happens when the user
/// leaves the field), the target's trapping effect is removed as well.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserEffectRemoved_TargetIsFreed()
{
// Arrange
var (script, move, target, targetVolatile, userVolatile) = CreateTestSetup();
script.OnSecondaryEffect(move, target, 0);
// Act - simulate the user leaving battle by removing its linked volatile
userVolatile.Remove(ScriptUtils.ResolveName<MeanLookEffectUser>());
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<MeanLookEffectTarget>())).IsFalse();
}
/// <summary>
/// Technical test: when the trapping volatile is blocked from being added to the target (the script
/// set's <see cref="IScriptSet.Add"/> returns null), the hit is marked as failed and no linked effect is
/// added to the user.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EffectBlocked_HitFails()
{
// Arrange
var script = new MeanLook();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
var userVolatile = Substitute.For<IScriptSet>();
user.Volatile.Returns(userVolatile);
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
var targetVolatile = Substitute.For<IScriptSet>();
targetVolatile.Add(Arg.Any<Script>(), Arg.Any<bool>()).Returns((ScriptContainer?)null);
target.Volatile.Returns(targetVolatile);
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(userVolatile.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Add")).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation VI onward): "Ghost-type Pokémon become immune to Mean Look."
/// Using Mean Look on a Ghost-type target should not trap it, so no <see cref="MeanLookEffectTarget"/>
/// may be added.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GhostTypeTarget_DoesNotTrapTarget()
{
// Arrange
var (script, move, target, targetVolatile, _) = CreateTestSetup();
target.Types.Returns([new TypeIdentifier(8, new StringKey("ghost"))]);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<MeanLookEffectTarget>())).IsFalse();
}
}

View File

@@ -0,0 +1,156 @@
using PkmnLib.Dynamic.Events;
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="Memento"/> move script.
/// Gen VII Bulbapedia behavior: "Memento causes the user to faint and in return lowers the Attack and
/// Special Attack stat of the target by two stages each." From Generation V onward the user does not faint
/// if the move fails (no target, miss, blocked by a substitute) - that part is engine behavior, since
/// <see cref="Memento.OnSecondaryEffect"/> only runs when the move successfully hits.
/// </summary>
public class MementoTests
{
private static (Memento script, IExecutingMove move, IPokemon target, IPokemon user) CreateTestSetup()
{
var script = new Memento();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
return (script, move, target, user);
}
/// <summary>
/// Helper to extract the arguments of the received ChangeStatBoost call for the given stat, or null
/// when no boost for that stat was requested. Received-call inspection is used instead of NSubstitute
/// argument matchers because the trailing <see cref="EventBatchId"/> parameter cannot be bound by
/// <c>Arg.Any</c>.
/// </summary>
private static object?[]? GetStatBoostArgs(IPokemon pokemon, Statistic stat) =>
pokemon.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost").Select(c => c.GetArguments())
.FirstOrDefault(args => (Statistic)args[0]! == stat);
/// <summary>
/// Bulbapedia: "Memento causes the user to faint and in return lowers the Attack and Special Attack stat
/// of the target by two stages each."
/// The Attack drop is caused by the opponent's move, so it is not self-inflicted and not forced.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Hit_LowersTargetAttackByTwoStages()
{
// Arrange
var (script, move, target, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var args = GetStatBoostArgs(target, Statistic.Attack);
await Assert.That(args).IsNotNull();
await Assert.That((sbyte)args![1]!).IsEqualTo((sbyte)-2);
await Assert.That((bool)args[2]!).IsFalse();
await Assert.That((bool)args[3]!).IsFalse();
}
/// <summary>
/// Bulbapedia: "Memento causes the user to faint and in return lowers the Attack and Special Attack stat
/// of the target by two stages each."
/// The Special Attack drop is caused by the opponent's move, so it is not self-inflicted and not forced.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Hit_LowersTargetSpecialAttackByTwoStages()
{
// Arrange
var (script, move, target, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var args = GetStatBoostArgs(target, Statistic.SpecialAttack);
await Assert.That(args).IsNotNull();
await Assert.That((sbyte)args![1]!).IsEqualTo((sbyte)-2);
await Assert.That((bool)args[2]!).IsFalse();
await Assert.That((bool)args[3]!).IsFalse();
}
/// <summary>
/// Bulbapedia: "Memento causes the user to faint and in return lowers the Attack and Special Attack stat
/// of the target by two stages each."
/// Only those two stats are lowered; no other stat changes are requested.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Hit_OnlyLowersAttackAndSpecialAttack()
{
// Arrange
var (script, move, target, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var statBoostCalls = target.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "ChangeStatBoost");
await Assert.That(statBoostCalls).IsEqualTo(2);
}
/// <summary>
/// Bulbapedia: "Memento causes the user to faint".
/// The faint is not move damage, so it uses <see cref="DamageSource.Misc"/>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Hit_UserFaints()
{
// Arrange
var (script, move, target, user) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var faintCall = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Faint");
await Assert.That(faintCall).IsNotNull();
await Assert.That((DamageSource)faintCall!.GetArguments()[0]!).IsEqualTo(DamageSource.Misc);
}
/// <summary>
/// Bulbapedia (Generation V onward): "the user faints if it successfully hits but cannot lower stats due
/// to abilities like Clear Body or stats already at -6."
/// A substitute's <see cref="IPokemon.ChangeStatBoost"/> returns false by default (the stat drops were
/// prevented), but the user must still faint.
/// </summary>
[Test]
public async Task OnSecondaryEffect_StatDropsPrevented_UserStillFaints()
{
// Arrange
var (script, move, target, user) = CreateTestSetup();
// Act - the unconfigured ChangeStatBoost calls return false, as if blocked by Clear Body
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Faint")).IsTrue();
}
/// <summary>
/// Technical test: both stat drops are batched under the same <see cref="EventBatchId"/> so they are
/// shown as a single event to the client.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Hit_StatDropsShareEventBatch()
{
// Arrange
var (script, move, target, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var attackBatch = (EventBatchId)GetStatBoostArgs(target, Statistic.Attack)![4]!;
var specialAttackBatch = (EventBatchId)GetStatBoostArgs(target, Statistic.SpecialAttack)![4]!;
await Assert.That(attackBatch).IsEqualTo(specialAttackBatch);
}
}

View File

@@ -0,0 +1,301 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
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="MetalBurst"/> script, which implements Metal Burst.
/// Behavior is verified against the Bulbapedia page for Metal Burst (Generation VII).
/// </summary>
public class MetalBurstTests
{
/// <summary>
/// Creates a Metal Burst user whose damage-tracking volatile has been installed through
/// <see cref="MetalBurst.OnBeforeTurnStart"/>. The user's <see cref="IPokemon.Volatile"/> is a real
/// <see cref="ScriptSet"/> so the internal tracker script can actually be stored and retrieved.
/// </summary>
private static (MetalBurst metalBurst, IPokemon user, IScriptSet userVolatile) CreateUserWithTracker()
{
var metalBurst = new MetalBurst();
var user = Substitute.For<IPokemon>();
var userVolatile = new ScriptSet(user);
user.Volatile.Returns(userVolatile);
// ScriptSet.Add runs the IScriptPreventVolatileAdd hook pass over the owner's scripts; give the mock a
// real (empty) iterator so that pass can run.
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var choice = Substitute.For<ITurnChoice>();
choice.User.Returns(user);
metalBurst.OnBeforeTurnStart(choice);
return (metalBurst, user, userVolatile);
}
/// <summary>
/// Creates an opposing Pokémon with a real (empty) <see cref="ScriptSet"/> as its volatile collection.
/// </summary>
private static IPokemon CreateOpponent()
{
var opponent = Substitute.For<IPokemon>();
opponent.Volatile.Returns(new ScriptSet(opponent));
return opponent;
}
/// <summary>
/// Simulates the user being hit by an opponent's move; the damage tracker installed on the user observes
/// this through its <see cref="IScriptOnIncomingHit"/> hook.
/// </summary>
private static void HitUser(IPokemon user, IPokemon attacker, uint damage,
MoveCategory category = MoveCategory.Physical, byte hit = 0)
{
var attackMove = Substitute.For<IExecutingMove>();
attackMove.User.Returns(attacker);
var moveData = Substitute.For<IMoveData>();
moveData.Category.Returns(category);
attackMove.UseMove.Returns(moveData);
var hitData = Substitute.For<IHitData>();
hitData.Damage.Returns(damage);
attackMove.GetHitData(user, hit).Returns(hitData);
var tracker = user.Volatile.Select(c => c.Script).OfType<IScriptOnIncomingHit>().Single();
tracker.OnIncomingHit(attackMove, user, hit);
}
/// <summary>
/// Creates the executing Metal Burst move used by <paramref name="user"/> against <paramref name="target"/>.
/// </summary>
private static (IExecutingMove move, IHitData hitData) CreateMetalBurstMove(IPokemon user, IPokemon target)
{
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
return (move, hitData);
}
/// <summary>
/// Helper that checks whether <see cref="IHitData.Fail"/> was called on the hit data.
/// </summary>
private static bool ReceivedFail(IHitData hitData) =>
hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail");
/// <summary>
/// Bulbapedia: "Metal Burst returns 1.5 times the damage dealt by the foe's last attack."
/// To know that damage, the script installs a damage-tracking volatile on the user just before the turn
/// starts.
/// </summary>
[Test]
public async Task OnBeforeTurnStart_Called_AddsDamageTrackerToUserVolatile()
{
// Arrange & Act
var (_, _, userVolatile) = CreateUserWithTracker();
// Assert
await Assert.That(userVolatile.Count).IsEqualTo(1);
}
/// <summary>
/// Bulbapedia: "Metal Burst returns 1.5 times the damage dealt by the foe's last attack."
/// The user is hit by the target, then retaliates with Metal Burst against that target; the damage must be
/// 1.5 times the damage taken (fractions truncated).
/// </summary>
[Test, Arguments(100u, 150u), Arguments(101u, 151u), Arguments(33u, 49u), Arguments(1u, 1u)]
public async Task ChangeMoveDamage_UserWasHitByTarget_DealsOneAndAHalfTimesDamageTaken(uint damageTaken,
uint expectedDamage)
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var attacker = CreateOpponent();
HitUser(user, attacker, damageTaken);
var (move, _) = CreateMetalBurstMove(user, attacker);
// Act
uint damage = 0;
metalBurst.ChangeMoveDamage(move, attacker, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: "Metal Burst returns 1.5 times the damage dealt by the foe's last attack."
/// When the user was hit by the target this turn, Metal Burst succeeds, so the hit must not be failed.
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserWasHitByTarget_DoesNotFailHit()
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var attacker = CreateOpponent();
HitUser(user, attacker, 100);
var (move, hitData) = CreateMetalBurstMove(user, attacker);
// Act
uint damage = 0;
metalBurst.ChangeMoveDamage(move, attacker, 0, ref damage);
// Assert
await Assert.That(ReceivedFail(hitData)).IsFalse();
}
/// <summary>
/// Bulbapedia: "if the user acts before it is hit by an opponent's damaging move, Metal Burst will fail."
/// If no damaging move hit the user this turn, the hit is failed and no damage is dealt.
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserNotHitThisTurn_FailsHit()
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var opponent = CreateOpponent();
var (move, hitData) = CreateMetalBurstMove(user, opponent);
// Act
uint damage = 0;
metalBurst.ChangeMoveDamage(move, opponent, 0, ref damage);
// Assert
await Assert.That(ReceivedFail(hitData)).IsTrue();
await Assert.That(damage).IsEqualTo(0u);
}
/// <summary>
/// Bulbapedia: "if the user acts before it is hit by an opponent's damaging move, Metal Burst will fail."
/// A status move is not a damaging move, so being targeted by only a status move must still make Metal
/// Burst fail.
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserOnlyHitByStatusMove_FailsHit()
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var attacker = CreateOpponent();
HitUser(user, attacker, 0, MoveCategory.Status);
var (move, hitData) = CreateMetalBurstMove(user, attacker);
// Act
uint damage = 0;
metalBurst.ChangeMoveDamage(move, attacker, 0, ref damage);
// Assert
await Assert.That(ReceivedFail(hitData)).IsTrue();
}
/// <summary>
/// Bulbapedia: "Only the last damage taken will be counted, if the user is hit by a multi-hit move, only
/// the damage from the final hit will be counted."
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserHitByMultiHitMove_OnlyFinalHitCounted()
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var attacker = CreateOpponent();
HitUser(user, attacker, 40, hit: 0);
HitUser(user, attacker, 100, hit: 1);
var (move, _) = CreateMetalBurstMove(user, attacker);
// Act
uint damage = 0;
metalBurst.ChangeMoveDamage(move, attacker, 0, ref damage);
// Assert - 1.5 times the final hit's 100 damage, not the earlier 40.
await Assert.That(damage).IsEqualTo(150u);
}
/// <summary>
/// Bulbapedia: "In battles involving multiple Pokémon, Metal Burst will hit the last opponent that dealt
/// damage to the user".
/// When two opponents hit the user, retaliating against the last one uses that opponent's damage.
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserHitByTwoOpponents_UsesDamageOfLastAttacker()
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var firstAttacker = CreateOpponent();
var lastAttacker = CreateOpponent();
HitUser(user, firstAttacker, 50);
HitUser(user, lastAttacker, 80);
var (move, _) = CreateMetalBurstMove(user, lastAttacker);
// Act
uint damage = 0;
metalBurst.ChangeMoveDamage(move, lastAttacker, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(120u);
}
/// <summary>
/// Bulbapedia: "Metal Burst will hit the last opponent that dealt damage to the user".
/// Hitting an opponent that is not the last Pokémon that damaged the user must fail.
/// </summary>
[Test]
public async Task ChangeMoveDamage_TargetIsNotLastAttacker_FailsHit()
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var firstAttacker = CreateOpponent();
var lastAttacker = CreateOpponent();
HitUser(user, firstAttacker, 50);
HitUser(user, lastAttacker, 80);
var (move, hitData) = CreateMetalBurstMove(user, firstAttacker);
// Act
uint damage = 0;
metalBurst.ChangeMoveDamage(move, firstAttacker, 0, ref damage);
// Assert
await Assert.That(ReceivedFail(hitData)).IsTrue();
}
/// <summary>
/// Bulbapedia: "In battles involving multiple Pokémon, Metal Burst will hit the last opponent that dealt
/// damage to the user" — like <see cref="Counter"/>, the script must implement
/// <see cref="IScriptChangeTargets"/> and redirect the move at the last attacker.
/// </summary>
[Test]
public async Task ChangeTargets_UserHitByOpponents_RedirectsToLastAttacker()
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var firstAttacker = CreateOpponent();
var lastAttacker = CreateOpponent();
HitUser(user, firstAttacker, 50);
HitUser(user, lastAttacker, 80);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
IReadOnlyList<IPokemon?> targets = [firstAttacker];
// Act & Assert - the script must expose the redirection hook and point the move at the last attacker
var changeTargets = metalBurst as IScriptChangeTargets;
await Assert.That(changeTargets).IsNotNull();
changeTargets!.ChangeTargets(choice, ref targets);
await Assert.That(targets.Count).IsEqualTo(1);
await Assert.That(targets[0]).IsEqualTo(lastAttacker);
}
/// <summary>
/// Bulbapedia: "Metal Burst returns 1.5 times the damage dealt by the foe's last attack." combined with
/// "if the user acts before it is hit by an opponent's damaging move, Metal Burst will fail."
/// Only damage taken during the current turn counts, so the damage tracker must be cleaned up at the end
/// of the turn.
/// </summary>
[Test]
public async Task OnEndTurn_Called_RemovesDamageTrackerFromUser()
{
// Arrange
var (_, user, userVolatile) = CreateUserWithTracker();
var tracker = userVolatile.At(0).Script!;
// Act
((IScriptOnEndTurn)tracker).OnEndTurn(user, Substitute.For<IBattle>());
// Assert
await Assert.That(userVolatile.Get(new StringKey("metal_burst_helper"))).IsNull();
}
}

View File

@@ -0,0 +1,168 @@
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static.Libraries;
using PkmnLib.Static.Moves;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Metronome"/> script, which implements Metronome.
/// Behavior is verified against the Bulbapedia page for Metronome (Generation VII).
/// </summary>
public class MetronomeTests
{
/// <summary>
/// Creates a fully mocked test setup for Metronome tests. The user's move library and battle random are
/// mocked so that tests control which move <see cref="Metronome.ChangeMove"/> "randomly" rolls.
/// </summary>
private static (Metronome metronome, IMoveChoice choice, IPokemon user, IReadOnlyMoveLibrary moveLibrary,
IBattleRandom random) CreateTestSetup()
{
var metronome = new Metronome();
var user = Substitute.For<IPokemon>();
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
var battle = Substitute.For<IBattle>();
var random = Substitute.For<IBattleRandom>();
battle.Random.Returns(random);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
user.BattleData.Returns(battleData);
var moveLibrary = Substitute.For<IReadOnlyMoveLibrary>();
var staticLibrary = Substitute.For<IStaticLibrary>();
staticLibrary.Moves.Returns(moveLibrary);
var dynamicLibrary = Substitute.For<IDynamicLibrary>();
dynamicLibrary.StaticLibrary.Returns(staticLibrary);
user.Library.Returns(dynamicLibrary);
return (metronome, choice, user, moveLibrary, random);
}
/// <summary>
/// Creates a mocked <see cref="IMoveData"/> with the given name.
/// </summary>
private static IMoveData CreateMoveData(string name)
{
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new StringKey(name));
return moveData;
}
/// <summary>
/// Bulbapedia: "The Metronome user executes a randomly selected move."
/// The move rolled from the move library replaces the used move name.
/// </summary>
[Test]
public async Task ChangeMove_SelectableMoveRolled_MoveNameChangesToRolledMove()
{
// Arrange
var (metronome, choice, _, moveLibrary, random) = CreateTestSetup();
var tackle = CreateMoveData("tackle");
moveLibrary.GetRandom(random).Returns(tackle);
var moveName = new StringKey("metronome");
// Act
metronome.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("tackle"));
}
/// <summary>
/// Technical test: if the user has no <see cref="IPokemon.BattleData"/>, the script cannot roll a random
/// move and must leave the move name unchanged.
/// </summary>
[Test]
public async Task ChangeMove_UserHasNoBattleData_MoveNameUnchanged()
{
// Arrange
var (metronome, choice, user, moveLibrary, random) = CreateTestSetup();
user.BattleData.Returns((IPokemonBattleData?)null);
var tackle = CreateMoveData("tackle");
moveLibrary.GetRandom(random).Returns(tackle);
var moveName = new StringKey("metronome");
// Act
metronome.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("metronome"));
}
/// <summary>
/// Bulbapedia: "Metronome cannot select a Max Move, a Z-Move, or any of the moves" in its list of
/// unselectable moves. When an unselectable move is rolled, the script rerolls until it finds a
/// selectable one.
/// </summary>
[Test]
public async Task ChangeMove_UnselectableMoveRolledFirst_RerollsUntilSelectableMove()
{
// Arrange
var (metronome, choice, _, moveLibrary, random) = CreateTestSetup();
var protect = CreateMoveData("protect");
var tackle = CreateMoveData("tackle");
moveLibrary.GetRandom(random).Returns(protect, tackle);
var moveName = new StringKey("metronome");
// Act
metronome.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("tackle"));
}
/// <summary>
/// Bulbapedia: "Metronome cannot select a Max Move, a Z-Move, or any of the moves" in its list of
/// unselectable moves. A sample of moves that are unselectable in Generation VII (including Metronome
/// itself) is rolled first; the script must skip them and use the next (selectable) roll instead.
/// </summary>
[Test, Arguments("metronome"), Arguments("protect"), Arguments("detect"), Arguments("endure"), Arguments("counter"),
Arguments("mirror_coat"), Arguments("mirror_move"), Arguments("mimic"), Arguments("sketch"),
Arguments("sleep_talk"), Arguments("struggle"), Arguments("transform"), Arguments("destiny_bond"),
Arguments("focus_punch"), Arguments("helping_hand"), Arguments("assist"), Arguments("copycat"),
Arguments("chatter"), Arguments("after_you"), Arguments("quash"), Arguments("wide_guard"),
Arguments("quick_guard"), Arguments("instruct"), Arguments("spectral_thief"), Arguments("crafty_shield"),
Arguments("snore")]
public async Task ChangeMove_Gen7UnselectableMoveRolled_IsNeverSelected(string unselectableMove)
{
// Arrange
var (metronome, choice, _, moveLibrary, random) = CreateTestSetup();
var rolled = CreateMoveData(unselectableMove);
var tackle = CreateMoveData("tackle");
moveLibrary.GetRandom(random).Returns(rolled, tackle);
var moveName = new StringKey("metronome");
// Act
metronome.ChangeMove(choice, ref moveName);
// Assert - the unselectable move was skipped in favor of the next roll.
await Assert.That(moveName).IsEqualTo(new StringKey("tackle"));
}
/// <summary>
/// Bulbapedia: "The Metronome user executes a randomly selected move." Moves such as Dig, Fly, and Roar do
/// not appear in the Generation VII column of Bulbapedia's list of moves Metronome cannot select, so
/// Metronome must be able to call them (they are only excluded for other copying moves such as Assist).
/// </summary>
[Test, Arguments("dig"), Arguments("fly"), Arguments("roar")]
public async Task ChangeMove_MoveSelectableByMetronomeButBannedForAssistRolled_IsSelected(string selectableMove)
{
// Arrange
var (metronome, choice, _, moveLibrary, random) = CreateTestSetup();
var rolled = CreateMoveData(selectableMove);
var tackle = CreateMoveData("tackle");
moveLibrary.GetRandom(random).Returns(rolled, tackle);
var moveName = new StringKey("metronome");
// Act
metronome.ChangeMove(choice, ref moveName);
// Assert - the first roll is selectable by Metronome in Generation VII, so it must be used.
await Assert.That(moveName).IsEqualTo(new StringKey(selectableMove));
}
}

View File

@@ -0,0 +1,178 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
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="Mimic"/> script, which implements Mimic.
/// Behavior is verified against the Bulbapedia page for Mimic (Generation VII).
/// The script is expected to copy the target's last used move through the engine's
/// <see cref="IScriptOnSecondaryEffect"/> hook, the same pattern used by <see cref="Sketch"/>.
/// </summary>
public class MimicTests
{
private const string NotImplementedReason =
"Mimic is not implemented: Mimic.cs contains only a FIXME comment and implements no script hooks, so " +
"the move has no effect (Bulbapedia: 'Mimic copies a move from the target')";
/// <summary>
/// Creates a mocked <see cref="ILearnedMove"/> whose move data has the given name.
/// </summary>
private static ILearnedMove CreateLearnedMove(string name)
{
var learned = Substitute.For<ILearnedMove>();
var data = Substitute.For<IMoveData>();
data.Name.Returns(new StringKey(name));
learned.MoveData.Returns(data);
return learned;
}
/// <summary>
/// Creates a fully mocked executing Mimic move. The user knows one other move in slot 0 and Mimic in
/// slot 1. If <paramref name="targetLastUsedMove"/> is null, the target has not used a move yet.
/// </summary>
private static (IExecutingMove move, IPokemon user, IPokemon target, IHitData hitData) CreateTestSetup(
string? targetLastUsedMove, string userOtherMove = "swords_dance")
{
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
var target = Substitute.For<IPokemon>();
move.User.Returns(user);
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
// The user knows another move in slot 0, and Mimic in slot 1; Mimic is the chosen move.
var mimicMove = CreateLearnedMove("mimic");
move.ChosenMove.Returns(mimicMove);
user.Moves.Returns(new[] { CreateLearnedMove(userOtherMove), mimicMove });
var battleData = Substitute.For<IPokemonBattleData>();
if (targetLastUsedMove != null)
{
var lastChoice = Substitute.For<IMoveChoice>();
var lastUsedMove = CreateLearnedMove(targetLastUsedMove);
lastChoice.ChosenMove.Returns(lastUsedMove);
battleData.LastMoveChoice.Returns(lastChoice);
}
else
{
battleData.LastMoveChoice.Returns((IMoveChoice?)null);
}
target.BattleData.Returns(battleData);
return (move, user, target, hitData);
}
/// <summary>
/// Asserts that the <see cref="Mimic"/> script implements the secondary effect hook, and returns it as
/// that hook. Every behavior of Mimic requires this hook to exist at all.
/// </summary>
private static async Task<IScriptOnSecondaryEffect> GetSecondaryEffectHook()
{
var mimic = new Mimic();
await Assert.That(mimic is IScriptOnSecondaryEffect).IsTrue();
return (IScriptOnSecondaryEffect)(object)mimic;
}
/// <summary>
/// Helper that checks whether <see cref="IHitData.Fail"/> was called on the hit data.
/// </summary>
private static bool ReceivedFail(IHitData hitData) =>
hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail");
/// <summary>
/// Helper to extract the arguments of the user's received <see cref="IPokemon.LearnMove"/> call.
/// </summary>
private static (StringKey moveName, byte index)? GetLearnedMove(IPokemon user)
{
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "LearnMove");
if (call == null)
return null;
return ((StringKey)call.GetArguments()[0]!, (byte)call.GetArguments()[2]!);
}
/// <summary>
/// Bulbapedia: "Mimic copies a move from the target. The user will retain the copied move in Mimic's
/// place" and (Generation II onwards) "Mimic now copies the target's last used move."
/// The target's last used move must be learned in the move slot that holds Mimic.
/// </summary>
[Test, TestFailing(NotImplementedReason)]
public async Task OnSecondaryEffect_TargetUsedMove_UserLearnsTargetsLastMoveInMimicsSlot()
{
// Arrange
var mimic = await GetSecondaryEffectHook();
var (move, user, target, _) = CreateTestSetup("tackle");
// Act
mimic.OnSecondaryEffect(move, target, 0);
// Assert - the copied move replaces Mimic, which sits in slot 1.
var learned = GetLearnedMove(user);
await Assert.That(learned.HasValue).IsTrue();
await Assert.That(learned!.Value.moveName).IsEqualTo(new StringKey("tackle"));
await Assert.That(learned.Value.index).IsEqualTo((byte)1);
}
/// <summary>
/// Bulbapedia: (Generation II onwards) "Mimic now copies the target's last used move."
/// If the target has not used a move yet, there is nothing to copy and the hit must fail.
/// </summary>
[Test, TestFailing(NotImplementedReason)]
public async Task OnSecondaryEffect_TargetHasNotUsedAMove_FailsHit()
{
// Arrange
var mimic = await GetSecondaryEffectHook();
var (move, user, target, hitData) = CreateTestSetup(null);
// Act
mimic.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedFail(hitData)).IsTrue();
await Assert.That(GetLearnedMove(user).HasValue).IsFalse();
}
/// <summary>
/// Bulbapedia: (Generation II onwards) "It now fails to copy the moves Sketch, Transform, Struggle,
/// Metronome, or any move the user already knows.", (Generation IV onwards) "Mimic will fail to copy
/// Chatter.", and (Generation V onwards) "Mimic is no longer able to copy Transform."
/// </summary>
[Test, TestFailing(NotImplementedReason), Arguments("sketch"), Arguments("transform"), Arguments("struggle"),
Arguments("metronome"), Arguments("chatter")]
public async Task OnSecondaryEffect_TargetsLastMoveIsUncopyable_FailsHit(string uncopyableMove)
{
// Arrange
var mimic = await GetSecondaryEffectHook();
var (move, user, target, hitData) = CreateTestSetup(uncopyableMove);
// Act
mimic.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedFail(hitData)).IsTrue();
await Assert.That(GetLearnedMove(user).HasValue).IsFalse();
}
/// <summary>
/// Bulbapedia: (Generation II onwards) "It now fails to copy ... any move the user already knows."
/// </summary>
[Test, TestFailing(NotImplementedReason)]
public async Task OnSecondaryEffect_UserAlreadyKnowsTargetsLastMove_FailsHit()
{
// Arrange
var mimic = await GetSecondaryEffectHook();
// The target's last used move is Tackle, which the user already knows in slot 0.
var (move, user, target, hitData) = CreateTestSetup("tackle", "tackle");
// Act
mimic.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedFail(hitData)).IsTrue();
await Assert.That(GetLearnedMove(user).HasValue).IsFalse();
}
}

View File

@@ -0,0 +1,133 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="MiracleEye"/> move script.
/// Behavior is verified against the Bulbapedia page for Miracle Eye (Generation VII behavior: the
/// Generation IV base effect with the Generation V-to-VII changes applied).
/// The lasting part of the effect (removing the Dark type's immunity to Psychic moves and ignoring
/// evasion raises) is implemented by the <see cref="MiracleEyeEffect"/> volatile script, which the
/// move script is responsible for attaching to the target.
/// </summary>
public class MiracleEyeTests
{
/// <summary>
/// Creates a fully mocked test setup for Miracle Eye tests, with the target at the given evasion
/// stat stage.
/// </summary>
private static (MiracleEye script, IExecutingMove move, IPokemon target, IScriptSet volatileSet, IHitData hitData)
CreateTestSetup(sbyte evasionStage = 0)
{
var script = new MiracleEye();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
var volatileSet = Substitute.For<IScriptSet>();
target.Volatile.Returns(volatileSet);
target.StatBoost.Returns(new StatBoostStatisticSet
{
Evasion = evasionStage,
});
return (script, move, target, volatileSet, hitData);
}
/// <summary>
/// Helper to extract all stat boost changes requested on the target through
/// <see cref="IPokemon.ChangeStatBoost"/>.
/// </summary>
private static IReadOnlyList<(Statistic stat, sbyte amount)> GetStatBoostChanges(IPokemon target) =>
target.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost")
.Select(c => ((Statistic)c.GetArguments()[0]!, (sbyte)c.GetArguments()[1]!)).ToList();
/// <summary>
/// Bulbapedia: "If Miracle Eye's target is a Dark-type Pokémon, it also removes the target's
/// immunity to Psychic-type moves. Miracle Eye's effect ends when the target switches out."
/// The lasting effect is implemented by attaching a <see cref="MiracleEyeEffect"/> volatile script
/// to the target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Always_AddsMiracleEyeEffectToTargetVolatile()
{
// Arrange
var (script, move, target, volatileSet, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var addCall = volatileSet.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Add");
await Assert.That(addCall).IsNotNull();
await Assert.That(addCall!.GetArguments()[0] is MiracleEyeEffect).IsTrue();
}
/// <summary>
/// Bulbapedia: "Miracle Eye causes accuracy checks against the target to ignore changes to the
/// target's evasion stat stages if its evasion stat stage is greater than 0."
/// The engine models this by resetting a raised evasion stat stage back to zero (and preventing
/// further raises through <see cref="MiracleEyeEffect"/>).
/// </summary>
[Test, Arguments((sbyte)1, (sbyte)-1), Arguments((sbyte)3, (sbyte)-3), Arguments((sbyte)6, (sbyte)-6)]
public async Task OnSecondaryEffect_PositiveEvasionStage_EvasionStageResetToZero(sbyte evasionStage,
sbyte expectedChange)
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup(evasionStage);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var changes = GetStatBoostChanges(target);
await Assert.That(changes.Count).IsEqualTo(1);
await Assert.That(changes[0].stat).IsEqualTo(Statistic.Evasion);
await Assert.That(changes[0].amount).IsEqualTo(expectedChange);
}
/// <summary>
/// Bulbapedia: "Miracle Eye causes accuracy checks against the target to ignore changes to the
/// target's evasion stat stages if its evasion stat stage is greater than 0."
/// With a zero or lowered (negative) evasion stat stage the condition does not hold, so the stage
/// must be left untouched: a lowered evasion stage remains in effect.
/// </summary>
[Test, Arguments((sbyte)0), Arguments((sbyte)-1), Arguments((sbyte)-6)]
public async Task OnSecondaryEffect_NonPositiveEvasionStage_EvasionStageUnchanged(sbyte evasionStage)
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup(evasionStage);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
}
/// <summary>
/// Bulbapedia (Generations V to VII): "Miracle Eye will now fail if used against a Pokémon already
/// under its effect."
/// When the target already has the <see cref="MiracleEyeEffect"/> volatile script, the hit must fail.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetAlreadyUnderEffect_HitFails()
{
// Arrange
var (script, move, target, volatileSet, hitData) = CreateTestSetup();
volatileSet.Contains<MiracleEyeEffect>().Returns(true);
volatileSet.Contains(Arg.Any<StringKey>()).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue();
}
}

View File

@@ -0,0 +1,278 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
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="MirrorCoat"/> move script.
/// Gen VII Bulbapedia behavior: "If the last amount of damage done to the user before the use of Mirror
/// Coat is greater than 0 and was dealt by a special move, Mirror Coat will do twice as much damage as
/// taken by that attack to the opponent." Mirror Coat fails if the user was not hit, or was last hit by
/// a physical move.
/// The turn-scoped bookkeeping is implemented by a private "mirror_coat_helper" volatile script that the
/// move script attaches to its user at the start of the turn; because that helper type is private, it is
/// driven here through its <see cref="IScriptOnIncomingHit"/> and <see cref="IScriptOnEndTurn"/>
/// interfaces after retrieving it from the user's volatile script set.
/// </summary>
public class MirrorCoatTests
{
private static readonly StringKey HelperName = "mirror_coat_helper";
/// <summary>
/// Creates a Pokémon substitute with a real (empty) volatile script set, so that volatile lookups
/// behave like they would on a real Pokémon.
/// </summary>
private static IPokemon CreatePokemonWithEmptyVolatile()
{
var pokemon = Substitute.For<IPokemon>();
var volatileSet = new ScriptSet(pokemon);
pokemon.Volatile.Returns(volatileSet);
pokemon.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
return pokemon;
}
/// <summary>
/// Creates a Mirror Coat user that has declared Mirror Coat this turn: <see cref="MirrorCoat"/>'s
/// <c>OnBeforeTurnStart</c> has run, so the helper volatile script is attached to the user.
/// </summary>
private static (MirrorCoat script, IPokemon user) CreateMirrorCoatUser()
{
var script = new MirrorCoat();
var user = CreatePokemonWithEmptyVolatile();
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
script.OnBeforeTurnStart(choice);
return (script, user);
}
/// <summary>
/// Simulates the user being hit by a move of the given category dealing the given damage, by invoking
/// the helper volatile script's <see cref="IScriptOnIncomingHit.OnIncomingHit"/> hook.
/// </summary>
private static void ReceiveHit(IPokemon user, IPokemon attacker, uint damage,
MoveCategory category = MoveCategory.Special)
{
var incoming = Substitute.For<IExecutingMove>();
var useMove = Substitute.For<IMoveData>();
useMove.Category.Returns(category);
incoming.UseMove.Returns(useMove);
incoming.User.Returns(attacker);
var hitData = Substitute.For<IHitData>();
hitData.Damage.Returns(damage);
incoming.GetHitData(user, 0).Returns(hitData);
var helper = (IScriptOnIncomingHit)user.Volatile.Get(HelperName)!.Script!;
helper.OnIncomingHit(incoming, user, 0);
}
/// <summary>
/// Creates the executing Mirror Coat move of <paramref name="user"/> against <paramref name="target"/>.
/// </summary>
private static (IExecutingMove move, IHitData hitData) CreateExecutingMirrorCoat(IPokemon user, IPokemon target)
{
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
return (move, hitData);
}
/// <summary>
/// Bulbapedia: "If the last amount of damage done to the user before the use of Mirror Coat is
/// greater than 0 and was dealt by a special move, Mirror Coat will do twice as much damage as taken
/// by that attack to the opponent."
/// Mirror Coat watches for those hits over the whole turn: at the start of the turn it attaches its
/// helper volatile script to the user.
/// </summary>
[Test]
public async Task OnBeforeTurnStart_Always_AddsMirrorCoatHelperToUser()
{
// Arrange & Act
var (_, user) = CreateMirrorCoatUser();
// Assert
await Assert.That(user.Volatile.Contains(HelperName)).IsTrue();
}
/// <summary>
/// Bulbapedia: "If the last amount of damage done to the user before the use of Mirror Coat is
/// greater than 0 and was dealt by a special move, Mirror Coat will do twice as much damage as taken
/// by that attack to the opponent."
/// </summary>
[Test, Arguments(40u, 80u), Arguments(1u, 2u), Arguments(123u, 246u)]
public async Task ChangeMoveDamage_UserHitBySpecialMove_DealsDoubleTheDamageTaken(uint damageTaken,
uint expectedDamage)
{
// Arrange
var (script, user) = CreateMirrorCoatUser();
var attacker = CreatePokemonWithEmptyVolatile();
ReceiveHit(user, attacker, damageTaken);
var (move, hitData) = CreateExecutingMirrorCoat(user, attacker);
uint damage = 0;
// Act
script.ChangeMoveDamage(move, attacker, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(expectedDamage);
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: if the user "is hit multiple times by a multistrike move", only the final strike is
/// countered — the helper records the most recent hit, so Mirror Coat returns double the damage of
/// the last strike only.
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserHitMultipleTimes_CountersOnlyTheLastHit()
{
// Arrange
var (script, user) = CreateMirrorCoatUser();
var attacker = CreatePokemonWithEmptyVolatile();
ReceiveHit(user, attacker, 30);
ReceiveHit(user, attacker, 50);
var (move, _) = CreateExecutingMirrorCoat(user, attacker);
uint damage = 0;
// Act
script.ChangeMoveDamage(move, attacker, 0, ref damage);
// Assert - double the last hit (50), not the first (30) or the sum
await Assert.That(damage).IsEqualTo(100u);
}
/// <summary>
/// Bulbapedia: Mirror Coat fails if the user was not hit by an attack before it executes ("If the
/// last amount of damage done to the user before the use of Mirror Coat is greater than 0...").
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserNotHitThisTurn_FailsHit()
{
// Arrange
var (script, user) = CreateMirrorCoatUser();
var target = CreatePokemonWithEmptyVolatile();
var (move, hitData) = CreateExecutingMirrorCoat(user, target);
uint damage = 0;
// Act
script.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
hitData.Received(1).Fail();
await Assert.That(damage).IsEqualTo(0u);
}
/// <summary>
/// Bulbapedia: Mirror Coat only counters special moves ("...and was dealt by a special move");
/// it fails when the user was hit by a physical move instead.
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserHitByPhysicalMoveOnly_FailsHit()
{
// Arrange
var (script, user) = CreateMirrorCoatUser();
var attacker = CreatePokemonWithEmptyVolatile();
ReceiveHit(user, attacker, 40, MoveCategory.Physical);
var (move, hitData) = CreateExecutingMirrorCoat(user, attacker);
uint damage = 0;
// Act
script.ChangeMoveDamage(move, attacker, 0, ref damage);
// Assert
hitData.Received(1).Fail();
await Assert.That(damage).IsEqualTo(0u);
}
/// <summary>
/// Bulbapedia: "Mirror Coat will target the last opponent that dealt special damage to the attacker" —
/// against any Pokémon other than that last attacker, the hit fails.
/// </summary>
[Test]
public async Task ChangeMoveDamage_TargetIsNotTheLastAttacker_FailsHit()
{
// Arrange
var (script, user) = CreateMirrorCoatUser();
var attacker = CreatePokemonWithEmptyVolatile();
ReceiveHit(user, attacker, 40);
var someoneElse = CreatePokemonWithEmptyVolatile();
var (move, hitData) = CreateExecutingMirrorCoat(user, someoneElse);
uint damage = 0;
// Act
script.ChangeMoveDamage(move, someoneElse, 0, ref damage);
// Assert
hitData.Received(1).Fail();
await Assert.That(damage).IsEqualTo(0u);
}
/// <summary>
/// Bulbapedia: "Mirror Coat will target the last opponent that dealt special damage to the attacker" —
/// like <see cref="Counter"/>, the script must implement <see cref="IScriptChangeTargets"/> and redirect
/// the move at the Pokémon whose special move last damaged the user.
/// </summary>
[Test]
public async Task ChangeTargets_UserHitBySpecialMove_RedirectsToLastSpecialAttacker()
{
// Arrange
var (script, user) = CreateMirrorCoatUser();
var attacker = CreatePokemonWithEmptyVolatile();
ReceiveHit(user, attacker, 40);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
IReadOnlyList<IPokemon?> targets = [CreatePokemonWithEmptyVolatile()];
// Act & Assert - the script must expose the redirection hook and point the move at the attacker
var changeTargets = script as IScriptChangeTargets;
await Assert.That(changeTargets).IsNotNull();
changeTargets!.ChangeTargets(choice, ref targets);
await Assert.That(targets.Count).IsEqualTo(1);
await Assert.That(targets[0]).IsEqualTo(attacker);
}
/// <summary>
/// Bulbapedia (Generation IV onwards): "If the Pokémon using Mirror Coat is hit by a special move that
/// deals 0 damage, Mirror Coat becomes a special move with 1 base power." Being hit by a 0-damage
/// special move therefore must not make Mirror Coat fail.
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserHitBySpecialMoveDealingZeroDamage_DoesNotFail()
{
// Arrange
var (script, user) = CreateMirrorCoatUser();
var attacker = CreatePokemonWithEmptyVolatile();
ReceiveHit(user, attacker, 0);
var (move, hitData) = CreateExecutingMirrorCoat(user, attacker);
uint damage = 0;
// Act
script.ChangeMoveDamage(move, attacker, 0, ref damage);
// Assert
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsFalse();
}
/// <summary>
/// Bulbapedia: Mirror Coat counters "the last amount of damage done to the user before the use of
/// Mirror Coat" — i.e. damage taken within the same turn. The helper volatile script therefore
/// removes itself at the end of the turn, so a hit from a previous turn is never countered.
/// </summary>
[Test]
public async Task MirrorCoatHelper_OnEndTurn_RemovesItselfFromUser()
{
// Arrange
var (_, user) = CreateMirrorCoatUser();
var helper = (IScriptOnEndTurn)user.Volatile.Get(HelperName)!.Script!;
// Act
helper.OnEndTurn(user, Substitute.For<IBattle>());
// Assert
await Assert.That(user.Volatile.Contains(HelperName)).IsFalse();
}
}

View File

@@ -0,0 +1,306 @@
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="MirrorMove"/> move script.
/// Gen VII Bulbapedia behavior (Generations V to VII): "Mirror Move now targets a specific Pokémon on
/// the field, and copies the move most recently used by that Pokémon." The move fails if there is no
/// move to copy, or if the move to copy is one that cannot be copied (such as Mirror Move itself).
/// </summary>
public class MirrorMoveTests
{
/// <summary>
/// Creates a Pokémon on the battlefield at the given side and position.
/// </summary>
private static IPokemon CreateFieldPokemon(byte side, byte position, bool onField = true)
{
var pokemon = Substitute.For<IPokemon>();
var battleData = Substitute.For<IPokemonBattleData>();
battleData.IsOnBattlefield.Returns(onField);
battleData.SideIndex.Returns(side);
battleData.Position.Returns(position);
pokemon.BattleData.Returns(battleData);
return pokemon;
}
/// <summary>
/// Creates a move choice that was executed earlier in the battle: the given Pokémon used the given
/// move against the given target side and position.
/// </summary>
private static IMoveChoice CreatePastMoveChoice(string moveName, IPokemon user, byte targetSide,
byte targetPosition)
{
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new StringKey(moveName));
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
var choice = Substitute.For<IMoveChoice>();
choice.ChosenMove.Returns(learnedMove);
choice.User.Returns(user);
choice.TargetSide.Returns(targetSide);
choice.TargetPosition.Returns(targetPosition);
return choice;
}
/// <summary>
/// Creates the Mirror Move script together with a Mirror Move choice aimed at the given side and
/// position, inside a mocked battle with an (initially empty) turn history and an empty choice queue.
/// The targeted Pokémon is placed on the battlefield at that side and position and returned.
/// </summary>
private static (MirrorMove script, IMoveChoice choice, IBattle battle, IPokemon target) CreateMirrorMoveSetup(
byte targetSide, byte targetPosition)
{
var script = new MirrorMove();
var battle = Substitute.For<IBattle>();
battle.ChoiceQueue.Returns(new BattleChoiceQueue([]));
SetTurnHistory(battle);
var target = CreateFieldPokemon(targetSide, targetPosition);
battle.GetPokemon(targetSide, targetPosition).Returns(target);
var user = Substitute.For<IPokemon>();
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
user.BattleData.Returns(battleData);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
choice.TargetSide.Returns(targetSide);
choice.TargetPosition.Returns(targetPosition);
return (script, choice, battle, target);
}
/// <summary>
/// Sets the battle's turn choice history to a single turn containing the given choices, in order.
/// </summary>
private static void SetTurnHistory(IBattle battle, params ITurnChoice[] choices) =>
battle.PreviousTurnChoices.Returns(new IReadOnlyList<ITurnChoice>[] { choices });
/// <summary>
/// Bulbapedia (Generations V to VII): "Mirror Move now targets a specific Pokémon on the field, and
/// copies the move most recently used by that Pokémon."
/// The targeted Pokémon (side 1, position 0) used Thunderbolt against the user, so Mirror Move must
/// become Thunderbolt.
/// </summary>
[Test]
public async Task ChangeMove_TargetUsedMoveOnUser_CopiesThatMove()
{
// Arrange - the target (side 1, position 0) used Thunderbolt against the user (side 0, position 0)
var (script, choice, battle, target) = CreateMirrorMoveSetup(1, 0);
var thunderbolt = CreatePastMoveChoice("thunderbolt", target, 0, 0);
SetTurnHistory(battle, thunderbolt);
StringKey moveName = "mirror_move";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("thunderbolt"));
choice.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia (Generations V to VII): Mirror Move "copies the move most recently used by that
/// Pokémon." The targeted Pokémon used a self-targeted move (Swords Dance), which Mirror Move copies.
/// </summary>
[Test]
public async Task ChangeMove_TargetUsedSelfTargetedMove_CopiesThatMove()
{
// Arrange - the target (side 1, position 0) used Swords Dance on itself
var (script, choice, battle, target) = CreateMirrorMoveSetup(1, 0);
var swordsDance = CreatePastMoveChoice("swords_dance", target, 1, 0);
SetTurnHistory(battle, swordsDance);
StringKey moveName = "mirror_move";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("swords_dance"));
choice.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia (Generations V to VII): Mirror Move "copies the move most recently used by that
/// Pokémon" - with multiple candidate moves in the history, the most recent one is copied.
/// </summary>
[Test]
public async Task ChangeMove_MultipleMovesInHistory_CopiesMostRecentOne()
{
// Arrange - the target used Growl first and Swords Dance afterwards
var (script, choice, battle, target) = CreateMirrorMoveSetup(1, 0);
var growl = CreatePastMoveChoice("growl", target, 1, 0);
var swordsDance = CreatePastMoveChoice("swords_dance", target, 1, 0);
SetTurnHistory(battle, growl, swordsDance);
StringKey moveName = "mirror_move";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("swords_dance"));
choice.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia (Generations V to VII): Mirror Move "copies the move most recently used by that
/// Pokémon." A move that a different Pokémon aimed at the target was not used by the target, so with
/// no move used by the target itself, Mirror Move fails.
/// </summary>
[Test]
public async Task ChangeMove_MoveUsedByAnotherPokemonOnTarget_Fails()
{
// Arrange - an ally (side 0, position 1) used Tackle on the target (side 1, position 0);
// the target itself has not used a move
var (script, choice, battle, _) = CreateMirrorMoveSetup(1, 0);
var ally = CreateFieldPokemon(0, 1);
var tackle = CreatePastMoveChoice("tackle", ally, 1, 0);
SetTurnHistory(battle, tackle);
StringKey moveName = "mirror_move";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
choice.Received(1).Fail();
await Assert.That(moveName).IsEqualTo(new StringKey("mirror_move"));
}
/// <summary>
/// Bulbapedia: "The move fails if no moves were previously targeted at the user" (Generation I to IV)
/// / there is no move used by the target to copy (Generations V to VII): with an empty turn history,
/// Mirror Move fails.
/// </summary>
[Test]
public async Task ChangeMove_NoPreviousMoves_Fails()
{
// Arrange
var (script, choice, _, _) = CreateMirrorMoveSetup(1, 0);
StringKey moveName = "mirror_move";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
choice.Received(1).Fail();
await Assert.That(moveName).IsEqualTo(new StringKey("mirror_move"));
}
/// <summary>
/// Bulbapedia: the move fails "if it would copy itself" - and in general if the move to copy is one
/// that cannot be copied (such as Counter or Transform).
/// </summary>
[Test, Arguments("mirror_move"), Arguments("counter"), Arguments("transform")]
public async Task ChangeMove_LastMoveCannotBeCopied_Fails(string lastMove)
{
// Arrange - the target used a non-copyable move on itself
var (script, choice, battle, target) = CreateMirrorMoveSetup(1, 0);
var pastChoice = CreatePastMoveChoice(lastMove, target, 1, 0);
SetTurnHistory(battle, pastChoice);
StringKey moveName = "mirror_move";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
choice.Received(1).Fail();
await Assert.That(moveName).IsEqualTo(new StringKey("mirror_move"));
}
/// <summary>
/// Bulbapedia: "Mirror Move uses the last move targeted at the user by a Pokémon still on the field."
/// A move whose user has since left the battlefield is not considered, so Mirror Move fails.
/// </summary>
[Test]
public async Task ChangeMove_MoveUserNoLongerOnField_Fails()
{
// Arrange - the Pokémon that used the move has switched out since; a replacement (the current
// target at side 1, position 0) now stands in its spot
var (script, choice, battle, _) = CreateMirrorMoveSetup(1, 0);
var departed = CreateFieldPokemon(1, 0, false);
var swordsDance = CreatePastMoveChoice("swords_dance", departed, 1, 0);
SetTurnHistory(battle, swordsDance);
StringKey moveName = "mirror_move";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
choice.Received(1).Fail();
await Assert.That(moveName).IsEqualTo(new StringKey("mirror_move"));
}
/// <summary>
/// Bulbapedia: Mirror Move copies the move most recently used - i.e. before Mirror Move itself
/// executes. Choices that come after the currently executing Mirror Move choice in the turn history
/// (they have not happened yet) must be ignored.
/// </summary>
[Test]
public async Task ChangeMove_MovesAfterCurrentlyExecutingChoice_AreIgnored()
{
// Arrange - this turn: the target used Growl, then Mirror Move executes, then the target's
// later Swords Dance choice is still pending in the history
var (script, choice, battle, target) = CreateMirrorMoveSetup(1, 0);
var growl = CreatePastMoveChoice("growl", target, 1, 0);
var swordsDance = CreatePastMoveChoice("swords_dance", target, 1, 0);
SetTurnHistory(battle, growl, choice, swordsDance);
var queue = new BattleChoiceQueue([choice]);
queue.Dequeue(); // Makes the Mirror Move choice the last ran choice
battle.ChoiceQueue.Returns(queue);
StringKey moveName = "mirror_move";
// Act
script.ChangeMove(choice, ref moveName);
// Assert - only Growl was used before Mirror Move executed
await Assert.That(moveName).IsEqualTo(new StringKey("growl"));
choice.DidNotReceive().Fail();
}
/// <summary>
/// Technical test: outside of battle (no battle data) the script cannot resolve a move to copy, so
/// the move name must remain unchanged.
/// </summary>
[Test]
public async Task ChangeMove_NoBattleData_MoveNameUnchanged()
{
// Arrange
var script = new MirrorMove();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
StringKey moveName = "mirror_move";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("mirror_move"));
}
/// <summary>
/// Technical test: without a running choice queue (no turn is being executed) the script cannot
/// determine the current point in the turn, so the move name must remain unchanged.
/// </summary>
[Test]
public async Task ChangeMove_NoChoiceQueue_MoveNameUnchanged()
{
// Arrange
var (script, choice, battle, _) = CreateMirrorMoveSetup(1, 0);
battle.ChoiceQueue.Returns((BattleChoiceQueue?)null);
StringKey moveName = "mirror_move";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("mirror_move"));
}
}

View File

@@ -0,0 +1,181 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Side;
using PkmnLib.Static;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Mist"/> move script and the <see cref="MistEffect"/> side script it places.
/// Gen VII Bulbapedia behavior (Generation III onwards): "Mist now creates a Mist effect on the user's side
/// of the field that lasts for five turns. All Pokémon on the user's side of the field are protected while
/// Mist is active, including Pokémon switched in after it was created." "While Mist is in effect, other
/// Pokémon (including allies) cannot lower the stats of Pokémon protected by Mist using either moves or
/// Abilities (e.g. Intimidate)", though it does not prevent self-inflicted stat reductions.
/// </summary>
public class MistTests
{
/// <summary>
/// Creates a fully mocked test setup where the user's battle side has a real <see cref="ScriptSet"/> as
/// its volatile scripts, so the Mist side effect can actually be attached and inspected.
/// </summary>
private static (Mist script, IExecutingMove move, IBattleSide side, IScriptSet sideScripts) CreateTestSetup()
{
var script = new Mist();
var move = Substitute.For<IExecutingMove>();
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet sideScripts = new ScriptSet(side);
side.VolatileScripts.Returns(sideScripts);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.BattleSide.Returns(side);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
move.User.Returns(user);
return (script, move, side, sideScripts);
}
/// <summary>
/// Bulbapedia: "Mist now creates a Mist effect on the user's side of the field that lasts for five
/// turns." — using the move attaches the <see cref="MistEffect"/> side script to the user's side.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserInBattle_AddsMistEffectToUsersSide()
{
// Arrange
var (script, move, _, sideScripts) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName<MistEffect>())).IsTrue();
}
/// <summary>
/// Technical test: without battle data on the user (outside of battle) the effect does nothing and does
/// not throw.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserHasNoBattleData_DoesNothing()
{
// Arrange
var (script, move, _, sideScripts) = CreateTestSetup();
move.User.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(sideScripts.Count).IsEqualTo(0);
}
/// <summary>
/// Bulbapedia: "While Mist is in effect, other Pokémon (including allies) cannot lower the stats of
/// Pokémon protected by Mist using either moves or Abilities (e.g. Intimidate)." — a stat reduction
/// inflicted by another Pokémon is prevented by the <see cref="MistEffect"/> side script.
/// </summary>
[Test, Arguments(Statistic.Attack, (sbyte)-1), Arguments(Statistic.Defense, (sbyte)-2),
Arguments(Statistic.Speed, (sbyte)-6), Arguments(Statistic.Accuracy, (sbyte)-1)]
public async Task PreventStatBoostChange_StatLoweredByOpponent_Prevented(Statistic stat, sbyte amount)
{
// Arrange
var effect = new MistEffect();
var target = Substitute.For<IPokemon>();
var prevent = false;
// Act
effect.PreventStatBoostChange(target, stat, amount, false, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: Mist only stops other Pokémon from being able to "lower the stats of Pokémon protected by
/// Mist" — stat increases are not affected by Mist.
/// </summary>
[Test, Arguments((sbyte)1), Arguments((sbyte)2), Arguments((sbyte)6)]
public async Task PreventStatBoostChange_StatIncrease_NotPrevented(sbyte amount)
{
// Arrange
var effect = new MistEffect();
var target = Substitute.For<IPokemon>();
var prevent = false;
// Act
effect.PreventStatBoostChange(target, Statistic.Attack, amount, false, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Technical test: a stat change of zero stages is not a stat reduction, so Mist does not prevent it.
/// </summary>
[Test]
public async Task PreventStatBoostChange_ZeroChange_NotPrevented()
{
// Arrange
var effect = new MistEffect();
var target = Substitute.For<IPokemon>();
var prevent = false;
// Act
effect.PreventStatBoostChange(target, Statistic.Attack, 0, false, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia: "While Mist is in effect, other Pokémon (including allies) cannot lower the stats of
/// Pokémon protected by Mist" — Mist does not prevent self-inflicted stat reductions (such as the
/// Defense/Special Defense drops from the protected Pokémon's own Superpower or Close Combat).
/// </summary>
[Test]
public async Task PreventStatBoostChange_SelfInflictedReduction_NotPrevented()
{
// Arrange
var effect = new MistEffect();
var target = Substitute.For<IPokemon>();
var prevent = false;
// Act
effect.PreventStatBoostChange(target, Statistic.Defense, -1, true, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia: "Mist now creates a Mist effect on the user's side of the field that lasts for five
/// turns." — after five end-of-turn ticks the <see cref="MistEffect"/> removes itself from the side.
/// </summary>
[Test]
public async Task MistEffect_AfterFiveEndOfTurns_EffectExpires()
{
// Arrange
var (script, move, side, sideScripts) = CreateTestSetup();
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
var effect = sideScripts.Get<MistEffect>();
await Assert.That(effect).IsNotNull();
// Act - five turns pass
var battle = Substitute.For<IBattle>();
if (effect is IScriptOnEndTurn endTurn)
{
for (var i = 0; i < 5; i++)
endTurn.OnEndTurn(side, battle);
}
// Assert
await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName<MistEffect>())).IsFalse();
}
}

View File

@@ -0,0 +1,247 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
using MistyTerrainScript = PkmnLib.Plugin.Gen7.Scripts.Terrain.MistyTerrain;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="MistyTerrain"/> move script and the
/// <see cref="MistyTerrainScript"/> terrain script it activates.
/// Gen VII Bulbapedia behavior: "Misty Terrain creates terrain that envelops the field and replaces the
/// background environment and any other terrain that is already in effect." "This terrain has the following
/// effects on Pokémon that are on the ground [...]: It halves the power of Dragon-type moves used against
/// affected Pokémon [...]. It prevents affected Pokémon from being afflicted by non-volatile status
/// conditions". Generation VII: "Pokémon affected by Misty Terrain can no longer become confused."
/// </summary>
public class MistyTerrainTests
{
private static (MistyTerrain script, IExecutingMove move, IBattle battle) CreateMoveTestSetup()
{
var script = new MistyTerrain();
var move = Substitute.For<IExecutingMove>();
var battle = Substitute.For<IBattle>();
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, battle);
}
/// <summary>
/// Creates a mocked setup for the terrain script's base power hook, with a target that is either
/// grounded or floating and a hit of the given type (null for a typeless hit).
/// </summary>
private static (MistyTerrainScript terrain, IExecutingMove move, IPokemon target) CreateTerrainSetup(
bool targetFloating, string? moveType)
{
var terrain = new MistyTerrainScript();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.IsFloating.Returns(targetFloating);
var hitData = Substitute.For<IHitData>();
hitData.Type.Returns(moveType == null ? null : new TypeIdentifier(1, new StringKey(moveType)));
move.GetHitData(target, 0).Returns(hitData);
return (terrain, move, target);
}
/// <summary>
/// Bulbapedia: "Misty Terrain creates terrain that envelops the field and replaces the background
/// environment and any other terrain that is already in effect." — the move sets the battle's terrain to
/// the <see cref="MistyTerrainScript"/> terrain script.
/// </summary>
[Test]
public void OnSecondaryEffect_SetsMistyTerrainOnBattle()
{
// Arrange
var (script, move, battle) = CreateMoveTestSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
battle.Received(1).SetTerrain(ScriptUtils.ResolveName<MistyTerrainScript>());
}
/// <summary>
/// Technical test: without battle data on the user (outside of battle) the effect does nothing and
/// does not throw.
/// </summary>
[Test]
public void OnSecondaryEffect_UserHasNoBattleData_DoesNotSetTerrain()
{
// Arrange
var (script, move, battle) = CreateMoveTestSetup();
move.User.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
battle.DidNotReceiveWithAnyArgs().SetTerrain(default);
}
/// <summary>
/// Bulbapedia: "It halves the power of Dragon-type moves used against affected Pokémon (regardless of
/// whether the user of the move is affected by Misty Terrain)." Halving uses integer division.
/// </summary>
[Test, Arguments((ushort)100, (ushort)50), Arguments((ushort)95, (ushort)47), Arguments((ushort)1, (ushort)0),
Arguments((ushort)65535, (ushort)32767)]
public async Task ChangeBasePower_DragonMoveAgainstGroundedPokemon_PowerHalved(ushort basePower, ushort expected)
{
// Arrange
var (terrain, move, target) = CreateTerrainSetup(false, "dragon");
// Act
terrain.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expected);
}
/// <summary>
/// Bulbapedia: the terrain only affects "Pokémon that are on the ground" — a Dragon-type move used
/// against a floating (airborne) Pokémon keeps its full power.
/// </summary>
[Test]
public async Task ChangeBasePower_DragonMoveAgainstFloatingPokemon_PowerUnchanged()
{
// Arrange
var (terrain, move, target) = CreateTerrainSetup(true, "dragon");
ushort basePower = 100;
// Act
terrain.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)100);
}
/// <summary>
/// Bulbapedia: only "Dragon-type moves" have their power halved — moves of any other type are
/// unaffected.
/// </summary>
[Test, Arguments("fairy"), Arguments("normal")]
public async Task ChangeBasePower_NonDragonMove_PowerUnchanged(string type)
{
// Arrange
var (terrain, move, target) = CreateTerrainSetup(false, type);
ushort basePower = 100;
// Act
terrain.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)100);
}
/// <summary>
/// Technical test: a typeless hit (null hit type) is not a Dragon-type move, so its power is unchanged
/// and the script does not throw.
/// </summary>
[Test]
public async Task ChangeBasePower_TypelessMove_PowerUnchanged()
{
// Arrange
var (terrain, move, target) = CreateTerrainSetup(false, null);
ushort basePower = 100;
// Act
terrain.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)100);
}
/// <summary>
/// Bulbapedia: "It prevents affected Pokémon from being afflicted by non-volatile status conditions" —
/// a grounded Pokémon cannot be given any non-volatile status while Misty Terrain is active.
/// </summary>
[Test, Arguments("burned"), Arguments("paralyzed"), Arguments("poisoned"), Arguments("frozen"), Arguments("sleep")]
public async Task PreventStatusChange_GroundedPokemon_StatusPrevented(string status)
{
// Arrange
var terrain = new MistyTerrainScript();
var pokemon = Substitute.For<IPokemon>();
pokemon.IsFloating.Returns(false);
var preventStatus = false;
// Act
terrain.PreventStatusChange(pokemon, new StringKey(status), false, ref preventStatus);
// Assert
await Assert.That(preventStatus).IsTrue();
}
/// <summary>
/// Bulbapedia: "Rest will fail if used by an affected Pokémon." — even a self-inflicted status (Rest's
/// sleep) is prevented for grounded Pokémon.
/// </summary>
[Test]
public async Task PreventStatusChange_SelfInflictedSleep_Prevented()
{
// Arrange
var terrain = new MistyTerrainScript();
var pokemon = Substitute.For<IPokemon>();
pokemon.IsFloating.Returns(false);
var preventStatus = false;
// Act
terrain.PreventStatusChange(pokemon, new StringKey("sleep"), true, ref preventStatus);
// Assert
await Assert.That(preventStatus).IsTrue();
}
/// <summary>
/// Bulbapedia: the terrain only affects "Pokémon that are on the ground" — a floating (airborne)
/// Pokémon can still be afflicted by non-volatile status conditions.
/// </summary>
[Test]
public async Task PreventStatusChange_FloatingPokemon_StatusNotPrevented()
{
// Arrange
var terrain = new MistyTerrainScript();
var pokemon = Substitute.For<IPokemon>();
pokemon.IsFloating.Returns(true);
var preventStatus = false;
// Act
terrain.PreventStatusChange(pokemon, new StringKey("burned"), false, ref preventStatus);
// Assert
await Assert.That(preventStatus).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation VII): "Pokémon affected by Misty Terrain can no longer become confused." —
/// adding the <see cref="Confusion"/> volatile to a grounded Pokémon is prevented while Misty Terrain
/// is in effect.
/// </summary>
[Test]
public async Task PreventVolatileAdd_ConfusionOnGroundedPokemon_Prevented()
{
// Arrange
var terrain = new MistyTerrainScript();
var pokemon = Substitute.For<IPokemon>();
pokemon.IsFloating.Returns(false);
var preventVolatileAdd = false;
// Act - a correct implementation would prevent the Confusion volatile through this hook.
if (terrain is IScriptPreventVolatileAdd preventHook)
preventHook.PreventVolatileAdd(pokemon, new Confusion(), ref preventVolatileAdd);
// Assert
await Assert.That(preventVolatileAdd).IsTrue();
}
}

View File

@@ -0,0 +1,74 @@
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="MoongeistBeam"/> move script.
/// Gen VII Bulbapedia behavior: "Moongeist Beam inflicts damage, ignoring all ignorable Abilities during the
/// execution of that move. (Ignorable Abilities are most Abilities that could potentially negatively affect
/// the success, damage, or effects of a move if possessed by the target of a move or its allies—such as
/// Multiscale and Friend Guard.)"
/// </summary>
public class MoongeistBeamTests
{
/// <summary>
/// Bulbapedia: "Moongeist Beam inflicts damage, ignoring all ignorable Abilities during the execution of
/// that move." — when no categories are suppressed yet, the script creates the suppression list and adds
/// the <see cref="ScriptCategory.Ability"/> category to it.
/// </summary>
[Test]
public async Task OnBeforeAnyHookInvoked_NullList_CreatesListContainingAbilityCategory()
{
// Arrange
var script = new MoongeistBeam();
List<ScriptCategory>? suppressedCategories = null;
// Act
script.OnBeforeAnyHookInvoked(ref suppressedCategories);
// Assert
await Assert.That(suppressedCategories).IsNotNull();
await Assert.That(suppressedCategories!.Contains(ScriptCategory.Ability)).IsTrue();
}
/// <summary>
/// Bulbapedia: "ignoring all ignorable Abilities during the execution of that move" — when other script
/// categories are already suppressed, the Ability category is added without removing the existing
/// suppressions.
/// </summary>
[Test]
public async Task OnBeforeAnyHookInvoked_ExistingList_AddsAbilityCategoryAndKeepsExistingEntries()
{
// Arrange
var script = new MoongeistBeam();
List<ScriptCategory>? suppressedCategories = [ScriptCategory.ItemBattleTrigger];
// Act
script.OnBeforeAnyHookInvoked(ref suppressedCategories);
// Assert
await Assert.That(suppressedCategories!.Contains(ScriptCategory.Ability)).IsTrue();
await Assert.That(suppressedCategories!.Contains(ScriptCategory.ItemBattleTrigger)).IsTrue();
}
/// <summary>
/// Bulbapedia: only "ignorable Abilities" are ignored — Moongeist Beam does not suppress any other kind
/// of script (held items, status conditions, side effects, ...), so the script adds exactly the Ability
/// category and nothing else.
/// </summary>
[Test]
public async Task OnBeforeAnyHookInvoked_NullList_OnlySuppressesAbilityCategory()
{
// Arrange
var script = new MoongeistBeam();
List<ScriptCategory>? suppressedCategories = null;
// Act
script.OnBeforeAnyHookInvoked(ref suppressedCategories);
// Assert
await Assert.That(suppressedCategories!.Count).IsEqualTo(1);
await Assert.That(suppressedCategories![0]).IsEqualTo(ScriptCategory.Ability);
}
}

View File

@@ -0,0 +1,152 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Moonlight"/> move script.
/// Gen VII Bulbapedia behavior: "Moonlight restores the user's current HP based on the weather in the battle.
/// During no weather it restores ½ total HP, during harsh sunlight it restores ⅔ total HP, and during other
/// weather it restores ¼ total HP, rounded down." Generation VI onwards: "When used during strong winds it
/// restores ½ total HP, rounded down."
/// </summary>
public class MoonlightTests
{
/// <summary>
/// Creates a fully mocked test setup for Moonlight tests. Moonlight targets the user itself, so the
/// returned Pokémon is used as both the move's user and the target of the secondary effect.
/// </summary>
private static (Moonlight moonlight, IExecutingMove move, IPokemon user) CreateTestSetup(uint maxHp,
string? weatherName)
{
var moonlight = new Moonlight();
var move = Substitute.For<IExecutingMove>();
var battle = Substitute.For<IBattle>();
battle.WeatherName.Returns(weatherName == null ? (StringKey?)null : new StringKey(weatherName));
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
user.BoostedStats.Returns(new StatisticSet<uint>(maxHp, 0, 0, 0, 0, 0));
move.User.Returns(user);
return (moonlight, move, 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>
/// Bulbapedia: "During no weather it restores ½ total HP ... rounded down."
/// </summary>
[Test, Arguments(100u, 50u), Arguments(99u, 49u), Arguments(255u, 127u)]
public async Task OnSecondaryEffect_NoWeather_HealsHalfMaxHp(uint maxHp, uint expectedHeal)
{
// Arrange
var (moonlight, move, user) = CreateTestSetup(maxHp, null);
// Act
moonlight.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(GetHealAmount(user)!.Value).IsEqualTo(expectedHeal);
}
/// <summary>
/// Bulbapedia: "during harsh sunlight it restores ⅔ total HP ... rounded down."
/// </summary>
[Test, Arguments(100u, 66u), Arguments(99u, 66u), Arguments(300u, 200u)]
public async Task OnSecondaryEffect_HarshSunlight_HealsTwoThirdsMaxHp(uint maxHp, uint expectedHeal)
{
// Arrange
var (moonlight, move, user) = CreateTestSetup(maxHp, "harsh_sunlight");
// Act
moonlight.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(GetHealAmount(user)!.Value).IsEqualTo(expectedHeal);
}
/// <summary>
/// Bulbapedia: "during other weather it restores ¼ total HP, rounded down." Rain, hail and sandstorm all
/// count as "other weather".
/// </summary>
[Test, Arguments("rain", 100u, 25u), Arguments("hail", 100u, 25u), Arguments("sandstorm", 100u, 25u),
Arguments("rain", 103u, 25u), Arguments("sandstorm", 7u, 1u)]
public async Task OnSecondaryEffect_OtherWeather_HealsQuarterMaxHp(string weather, uint maxHp, uint expectedHeal)
{
// Arrange
var (moonlight, move, user) = CreateTestSetup(maxHp, weather);
// Act
moonlight.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(GetHealAmount(user)!.Value).IsEqualTo(expectedHeal);
}
/// <summary>
/// Bulbapedia (Generation VI onwards): "When used during strong winds it restores ½ total HP, rounded
/// down."
/// </summary>
[Test, Arguments(100u, 50u), Arguments(99u, 49u)]
public async Task OnSecondaryEffect_StrongWinds_HealsHalfMaxHp(uint maxHp, uint expectedHeal)
{
// Arrange
var (moonlight, move, user) = CreateTestSetup(maxHp, "strong_winds");
// Act
moonlight.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(GetHealAmount(user)!.Value).IsEqualTo(expectedHeal);
}
/// <summary>
/// Bulbapedia: "Moonlight restores the user's current HP" — the heal is applied to the move's user.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NoWeather_HealsTheUser()
{
// Arrange
var (moonlight, move, user) = CreateTestSetup(100, null);
// Act
moonlight.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Heal")).IsTrue();
}
/// <summary>
/// Technical test: without battle data on the user (outside of battle) the secondary effect does nothing
/// and does not throw.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserHasNoBattleData_DoesNotHeal()
{
// Arrange
var moonlight = new Moonlight();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
move.User.Returns(user);
// Act
moonlight.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Heal")).IsFalse();
}
}

Some files were not shown because too many files have changed in this diff Show More