This commit is contained in:
283
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FacadeTests.cs
Normal file
283
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FacadeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
120
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FakeOutTests.cs
Normal file
120
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FakeOutTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
187
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FeintTests.cs
Normal file
187
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FeintTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
224
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FireFangTests.cs
Normal file
224
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FireFangTests.cs
Normal 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>());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
245
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FireSpinTests.cs
Normal file
245
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FireSpinTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
113
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FlailTests.cs
Normal file
113
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FlailTests.cs
Normal 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 < 68.8%,
|
||||
/// 80 at 20.8% ≤ HP < 35.4%, 100 at 10.4% ≤ HP < 20.8%, 150 at 4.2% ≤ HP < 10.4%, and
|
||||
/// 200 at HP < 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 < 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 < 68.8%),
|
||||
/// 80 (20.8% ≤ HP < 35.4%), 100 (10.4% ≤ HP < 20.8%), 150 (4.2% ≤ HP < 10.4%) and 200 (HP < 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);
|
||||
}
|
||||
}
|
||||
@@ -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 V–VII 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
142
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FlatterTests.cs
Normal file
142
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FlatterTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
142
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FlingTests.cs
Normal file
142
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FlingTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
349
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FlyTests.cs
Normal file
349
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FlyTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
421
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FollowMeTests.cs
Normal file
421
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FollowMeTests.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
229
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/GearUpTests.cs
Normal file
229
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/GearUpTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
144
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/GeomancyTests.cs
Normal file
144
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/GeomancyTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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.1–9.9 kg → 20, 10.0–24.9 kg → 40, 25.0–49.9 kg → 60,
|
||||
/// 50.0–99.9 kg → 80, 100.0–199.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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
251
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/GravityTests.cs
Normal file
251
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/GravityTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
118
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/GrowthTests.cs
Normal file
118
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/GrowthTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
108
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/GyroBallTests.cs
Normal file
108
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/GyroBallTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
193
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/HealBellTests.cs
Normal file
193
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/HealBellTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
121
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/HexTests.cs
Normal file
121
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/HexTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 a–f 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 a–f 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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 <Pokémon> 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();
|
||||
}
|
||||
}
|
||||
226
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/IceBallTests.cs
Normal file
226
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/IceBallTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
270
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/IceBurnTests.cs
Normal file
270
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/IceBurnTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
224
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/IceFangTests.cs
Normal file
224
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/IceFangTests.cs
Normal 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>());
|
||||
}
|
||||
}
|
||||
161
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ImprisonTests.cs
Normal file
161
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ImprisonTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
237
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/IngrainTests.cs
Normal file
237
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/IngrainTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
169
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/InstructTests.cs
Normal file
169
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/InstructTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
136
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/KnockOffTests.cs
Normal file
136
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/KnockOffTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
158
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/LockOnTests.cs
Normal file
158
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/LockOnTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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.1–9.9 kg → 20, 10.0–24.9 kg → 40, 25.0–49.9 kg → 60,
|
||||
/// 50.0–99.9 kg → 80, 100.0–199.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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 (0–99) 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 <value>!").
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
202
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MatBlockTests.cs
Normal file
202
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MatBlockTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
294
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MeFirstTests.cs
Normal file
294
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MeFirstTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
188
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MeanLookTests.cs
Normal file
188
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MeanLookTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
156
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MementoTests.cs
Normal file
156
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MementoTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
178
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MimicTests.cs
Normal file
178
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MimicTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
181
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MistTests.cs
Normal file
181
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MistTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
172
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MudSportTests.cs
Normal file
172
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MudSportTests.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
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;
|
||||
using PkmnLib.Static.Moves;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="MudSport"/> move script and its attached <see cref="MudSportEffect"/>.
|
||||
/// Gen VII Bulbapedia behavior: "Mud Sport now reduces the base power of Electric-type moves by 67%
|
||||
/// (approximated as a factor of 1352/4096) on both sides in battle." and (Generations VI and VII):
|
||||
/// "Mud Sport is now active for five turns, even if the user switches out."
|
||||
/// </summary>
|
||||
public class MudSportTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a fully mocked test setup where the battle has a real <see cref="ScriptSet"/> as its volatile
|
||||
/// script set.
|
||||
/// </summary>
|
||||
private static (MudSport script, IExecutingMove move, IPokemon target, IBattle battle, IScriptSet battleVolatile)
|
||||
CreateTestSetup()
|
||||
{
|
||||
var script = new MudSport();
|
||||
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);
|
||||
var user = Substitute.For<IPokemon>();
|
||||
user.BattleData.Returns(battleData);
|
||||
move.User.Returns(user);
|
||||
|
||||
return (script, move, target, battle, battleVolatile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mocked executing move whose used move has the given type name.
|
||||
/// </summary>
|
||||
private static IExecutingMove CreateExecutingMoveOfType(string typeName)
|
||||
{
|
||||
var executingMove = Substitute.For<IExecutingMove>();
|
||||
var moveData = Substitute.For<IMoveData>();
|
||||
moveData.MoveType.Returns(new TypeIdentifier(1, typeName));
|
||||
executingMove.UseMove.Returns(moveData);
|
||||
return executingMove;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: Mud Sport reduces the base power of Electric-type moves "on both sides in battle" — using
|
||||
/// the move puts the <see cref="MudSportEffect"/> on the battle's volatile scripts so it affects the whole
|
||||
/// field, "even if the user switches out".
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_AddsMudSportEffectToBattleVolatile()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, _, battleVolatile) = CreateTestSetup();
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(battleVolatile.Contains(ScriptUtils.ResolveName<MudSportEffect>())).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 void OnSecondaryEffect_UserHasNoBattleData_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var script = new MudSport();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
var user = Substitute.For<IPokemon>();
|
||||
user.BattleData.Returns((IPokemonBattleData?)null);
|
||||
move.User.Returns(user);
|
||||
|
||||
// Act & Assert - should not throw
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Mud Sport now reduces the base power of Electric-type moves by 67% (approximated as a
|
||||
/// factor of 1352/4096) on both sides in battle."
|
||||
/// </summary>
|
||||
[Test, Arguments((ushort)100, (ushort)33), Arguments((ushort)4096, (ushort)1352)]
|
||||
public async Task ChangeBasePower_ElectricMove_PowerReducedBySixtySevenPercent(ushort basePower,
|
||||
ushort expectedPower)
|
||||
{
|
||||
// Arrange
|
||||
var effect = new MudSportEffect();
|
||||
var electricMove = CreateExecutingMoveOfType("electric");
|
||||
var target = Substitute.For<IPokemon>();
|
||||
|
||||
// Act
|
||||
effect.ChangeBasePower(electricMove, target, 0, ref basePower);
|
||||
|
||||
// Assert
|
||||
await Assert.That(basePower).IsEqualTo(expectedPower);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: Mud Sport only "reduces the base power of Electric-type moves" — moves of other types are
|
||||
/// unaffected.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ChangeBasePower_NonElectricMove_PowerUnchanged()
|
||||
{
|
||||
// Arrange
|
||||
var effect = new MudSportEffect();
|
||||
var fireMove = CreateExecutingMoveOfType("fire");
|
||||
var target = Substitute.For<IPokemon>();
|
||||
ushort basePower = 100;
|
||||
|
||||
// Act
|
||||
effect.ChangeBasePower(fireMove, target, 0, ref basePower);
|
||||
|
||||
// Assert
|
||||
await Assert.That(basePower).IsEqualTo((ushort)100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Mud Sport is now active for five turns" — after four end-of-turn ticks the
|
||||
/// <see cref="MudSportEffect"/> is still on the battle.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnEndTurn_FourTurns_EffectRemains()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, battle, battleVolatile) = CreateTestSetup();
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
var effect = battleVolatile.Get<MudSportEffect>()!;
|
||||
|
||||
// Act
|
||||
for (var turn = 0; turn < 4; turn++)
|
||||
effect.OnEndTurn(battle, battle);
|
||||
|
||||
// Assert
|
||||
await Assert.That(battleVolatile.Contains(ScriptUtils.ResolveName<MudSportEffect>())).IsTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Mud Sport is now active for five turns" — after the fifth end-of-turn tick the
|
||||
/// <see cref="MudSportEffect"/> removes itself.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnEndTurn_FiveTurns_EffectRemovesItself()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, battle, battleVolatile) = CreateTestSetup();
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
var effect = battleVolatile.Get<MudSportEffect>()!;
|
||||
|
||||
// Act
|
||||
for (var turn = 0; turn < 5; turn++)
|
||||
effect.OnEndTurn(battle, battle);
|
||||
|
||||
// Assert
|
||||
await Assert.That(battleVolatile.Contains(ScriptUtils.ResolveName<MudSportEffect>())).IsFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
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.Species;
|
||||
using PkmnLib.Static.Utils;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="MultiAttack"/> move script.
|
||||
/// Gen VII Bulbapedia behavior: "The type of Multi-Attack depends on the type of memory held by the user,
|
||||
/// being Normal-type if there is no held memory."
|
||||
/// </summary>
|
||||
public class MultiAttackTests
|
||||
{
|
||||
public record TestCaseData(string? ItemName, string ExpectedTypeName)
|
||||
@@ -88,4 +96,81 @@ public class MultiAttackTests
|
||||
// Assert
|
||||
await Assert.That(typeIdentifier!.Value.Name.ToString()).IsEqualTo(test.ExpectedTypeName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mocked executing move whose user holds a fire_memory, with a type library that knows both
|
||||
/// "normal" and "fire".
|
||||
/// </summary>
|
||||
private static (IExecutingMove move, IPokemon user) CreateMemoryHolderSetup()
|
||||
{
|
||||
var typeLibrary = new TypeLibrary();
|
||||
typeLibrary.RegisterType("normal");
|
||||
typeLibrary.RegisterType("fire");
|
||||
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var user = Substitute.For<IPokemon>();
|
||||
var dynamicLibrary = Substitute.For<IDynamicLibrary>();
|
||||
var staticLibrary = Substitute.For<IStaticLibrary>();
|
||||
var item = Substitute.For<IItem>();
|
||||
|
||||
user.Library.Returns(dynamicLibrary);
|
||||
dynamicLibrary.StaticLibrary.Returns(staticLibrary);
|
||||
staticLibrary.Types.Returns(typeLibrary);
|
||||
item.Name.Returns(new StringKey("fire_memory"));
|
||||
user.HeldItem.Returns(item);
|
||||
move.User.Returns(user);
|
||||
|
||||
return (move, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "If the user has Klutz, or if Magic Room is in effect, Multi-Attack's type will always be
|
||||
/// Normal regardless of the memory held." A Klutz user holding a fire_memory still uses a Normal-type
|
||||
/// Multi-Attack.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ChangeMoveType_UserHasKlutz_MoveTypeStaysNormal()
|
||||
{
|
||||
// Arrange
|
||||
var (move, user) = CreateMemoryHolderSetup();
|
||||
var klutz = Substitute.For<IAbility>();
|
||||
klutz.Name.Returns(new StringKey("klutz"));
|
||||
user.ActiveAbility.Returns(klutz);
|
||||
TypeIdentifier? typeIdentifier = new TypeIdentifier(1, "normal");
|
||||
var multiAttack = new MultiAttack();
|
||||
|
||||
// Act
|
||||
multiAttack.ChangeMoveType(move, Substitute.For<IPokemon>(), 0, ref typeIdentifier);
|
||||
|
||||
// Assert
|
||||
await Assert.That(typeIdentifier!.Value.Name.ToString()).IsEqualTo("normal");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "If the user has Klutz, or if Magic Room is in effect, Multi-Attack's type will always be
|
||||
/// Normal regardless of the memory held." While the <see cref="MagicRoomEffect"/> is on the battle, a held
|
||||
/// fire_memory does not change Multi-Attack's type.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ChangeMoveType_MagicRoomInEffect_MoveTypeStaysNormal()
|
||||
{
|
||||
// Arrange
|
||||
var (move, _) = CreateMemoryHolderSetup();
|
||||
|
||||
var battle = Substitute.For<IBattle>();
|
||||
battle.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
|
||||
IScriptSet battleVolatile = new ScriptSet(battle);
|
||||
battleVolatile.Add(new MagicRoomEffect());
|
||||
battle.Volatile.Returns(battleVolatile);
|
||||
move.Battle.Returns(battle);
|
||||
|
||||
TypeIdentifier? typeIdentifier = new TypeIdentifier(1, "normal");
|
||||
var multiAttack = new MultiAttack();
|
||||
|
||||
// Act
|
||||
multiAttack.ChangeMoveType(move, Substitute.For<IPokemon>(), 0, ref typeIdentifier);
|
||||
|
||||
// Assert
|
||||
await Assert.That(typeIdentifier!.Value.Name.ToString()).IsEqualTo("normal");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user