Many more tests and fixes
Build / Build (push) Successful in 3m12s

This commit is contained in:
Deukhoofd committed 2026-08-27 17:11:12 +02:00
1 parent 32b3ef9c4a
commit d2a82b5fe3
204 files changed
+20255 -242

No files matched your search

+6
View File
@@ -107,6 +107,12 @@ Follow the existing conventions — `Scripts/Moves/DrainTests.cs` and
division, overflow guards).
- Cover the negative cases the description implies (non-contact move → no trigger, holding an
item → no boost, attacker already fainted, null `BattleData`, ...).
- **Write the test even when the behavior is not implemented at all.** A described behavior the
script simply lacks (a missing check, a missing effect, a missing interface) still gets a test
encoding the correct Gen VII behavior — it will fail and be marked `[TestFailing]` in step 5,
which is the point: the gap stays tracked in the suite instead of in a report nobody rereads.
"Not implemented" is never a reason to skip writing the test, and reporting a known deviation
as a prose note instead of a `[TestFailing]` test is not acceptable.
- `LibraryHelpers.LoadLibrary()` is available when a test genuinely needs the full Gen7 library
(e.g. looking up real move data); prefer pure mocks otherwise.
+3
View File
@@ -40,6 +40,9 @@ public static class AIHelpers
/// <inheritdoc />
public uint Damage => 0;
/// <inheritdoc />
public bool HasExecuted => false;
/// <inheritdoc />
public TypeIdentifier? Type { get; init; }
@@ -142,6 +142,7 @@ public static class MoveTurnExecutor
if (isInvulnerable)
{
battle.EventHook.Invoke(new MoveInvulnerableEvent(executingMove, target));
executingMove.RunScriptHook<IScriptOnAfterHits>(x => x.OnAfterHits(executingMove, target));
return;
}
@@ -311,6 +312,7 @@ public static class MoveTurnExecutor
}
}
}
hitData.HasExecuted = true;
}
if (numberOfHits == 0)
+8
View File
@@ -32,6 +32,11 @@ public interface IHitData
/// </summary>
uint Damage { get; }
/// <summary>
/// Gets set to true after the hit has fully executed, and has not been interrupted by anything.
/// </summary>
bool HasExecuted { get; }
/// <summary>
/// The type of the hit. Null if the move is typeless.
/// </summary>
@@ -78,6 +83,9 @@ public record HitData : IHitData
/// <inheritdoc />
public uint Damage { get; internal set; }
/// <inheritdoc />
public bool HasExecuted { get; internal set; }
/// <inheritdoc />
public TypeIdentifier? Type { get; internal set; }
+7
View File
@@ -14,6 +14,13 @@ public abstract class Script : IDeepCloneable, IScriptOnRemove
{
internal event Action<Script>? OnRemoveEvent;
/// <summary>
/// Whether the script is a charge script that's considered a semi-invulnerable turn, such as fly, dig, etc.
///
/// see also: <a href="https://bulbapedia.bulbagarden.net/wiki/Semi-invulnerable_turn">Semi-invulnerable turn</a>
/// </summary>
public virtual bool IsSemiInvulnerableTurn => false;
/// <summary>
/// Remove the script from its owner.
/// </summary>
@@ -0,0 +1,115 @@
{
"name": "HighJumpKickCrashOnSemiInvulnerable",
"description": "A Hitmonlee uses High Jump Kick against a Crobat that flies up with Fly in the same turn. The faster Crobat becomes semi-invulnerable before the kick, so the kick misses and Hitmonlee takes crash damage equal to half its max HP, while Crobat takes no damage.",
"battleSetup": {
"seed": 10,
"canFlee": false,
"numberOfSides": 2,
"positionsPerSide": 1,
"hasDamageRandomness": false,
"parties": [
{
"indices": [
[
0,
0
]
],
"pokemon": [
{
"species": "crobat",
"level": 50,
"moves": [
"fly"
]
}
]
},
{
"indices": [
[
1,
0
]
],
"pokemon": [
{
"species": "hitmonlee",
"level": 50,
"moves": [
"high_jump_kick"
]
}
]
}
]
},
"actions": [
{
"$type": "setPokemon",
"place": [
0,
0
],
"fromParty": [
0,
0
]
},
{
"$type": "setPokemon",
"place": [
1,
0
],
"fromParty": [
1,
0
]
},
{
"$type": "assert",
"value": ".Sides[0].Pokemon[0].CurrentHealth",
"expected": 145
},
{
"$type": "assert",
"value": ".Sides[1].Pokemon[0].CurrentHealth",
"expected": 110
},
{
"$type": "setMoveChoice",
"place": [
0,
0
],
"move": "fly",
"target": [
1,
0
]
},
{
"$type": "setMoveChoice",
"place": [
1,
0
],
"move": "high_jump_kick",
"target": [
0,
0
]
},
{
"$type": "assert",
"value": ".Sides[0].Pokemon[0].CurrentHealth",
"expected": 145
},
{
"$type": "assert",
"value": ".Sides[1].Pokemon[0].CurrentHealth",
"expected": 55
}
]
}
@@ -0,0 +1,33 @@
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.DataTests;
/// <summary>
/// Data tests for the Gen7 item library.
/// </summary>
public class ItemDataTests
{
/// <summary>
/// Bulbapedia (Fling): "Fling inflicts damage; the power of the move is determined by the user's held
/// item." Canonical powers from the Fling item table: Iron Ball and Big Nugget 130, Hard Stone and
/// Rare Bone 100, Poison Barb 70, Damp Rock 60, Light Ball 30, Berries (Oran Berry) 10. The
/// <see cref="PkmnLib.Plugin.Gen7.Scripts.Moves.Fling"/> script reads these from the item's
/// "fling_power" additional data, so the data must define them.
/// </summary>
[Test, Arguments("iron_ball", (byte)130), Arguments("big_nugget", (byte)30), Arguments("hard_stone", (byte)100),
Arguments("rare_bone", (byte)100), Arguments("poison_barb", (byte)70), Arguments("damp_rock", (byte)60),
Arguments("light_ball", (byte)30), Arguments("oran_berry", (byte)10)]
public async Task Items_CanonicalFlingItems_DefineFlingPower(string itemName, byte expectedFlingPower)
{
// Arrange
var library = LibraryHelpers.LoadLibrary();
// Act
var found = library.StaticLibrary.Items.TryGet(new StringKey(itemName), out var item);
// Assert
await Assert.That(found).IsTrue();
await Assert.That(item!.TryGetAdditionalData<byte>(new StringKey("flingPower"), out var flingPower)).IsTrue();
await Assert.That(flingPower).IsEqualTo(expectedFlingPower);
}
}
@@ -0,0 +1,222 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.MoveVolatile;
using PkmnLib.Plugin.Gen7.Scripts.Side;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.MoveVolatile;
/// <summary>
/// Tests for the <see cref="FireGrassPledgeMove"/> volatile script, which implements the combined move of
/// Fire Pledge and Grass Pledge, and the <see cref="SeaOfFireEffect"/> it creates.
/// Bulbapedia (Fire Pledge): "If Fire Pledge and Grass Pledge are used in the same turn, the ally moving
/// second will use Fire Pledge with a power of 150, and create a sea of fire on the target's side of the
/// field for four turns. The sea of fire damages all non-Fire Pokémon on that side of the field for 1/8 of
/// their maximum HP at the end of each turn."
/// </summary>
public class FireGrassPledgeMoveTests
{
/// <summary>
/// Creates a fully mocked test setup. The target's battle side gets a real <see cref="ScriptSet"/> as
/// its volatile script set, so the sea of fire created by the combined move can be inspected and driven.
/// </summary>
private static (FireGrassPledgeMove script, IExecutingMove move, IPokemon target, IBattleSide side, IScriptSet
sideVolatile) CreateTestSetup()
{
var script = new FireGrassPledgeMove();
var move = Substitute.For<IExecutingMove>();
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var sideVolatile = new ScriptSet(side);
side.VolatileScripts.Returns(sideVolatile);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.BattleSide.Returns(side);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
return (script, move, target, side, sideVolatile);
}
/// <summary>
/// Creates a Pokémon substitute of the given type with the given maximum HP, for placing on the side
/// covered by the sea of fire.
/// </summary>
private static IPokemon CreateSidePokemon(string typeName, uint maxHealth)
{
var pokemon = Substitute.For<IPokemon>();
pokemon.Types.Returns(new List<TypeIdentifier> { new(1, typeName) });
pokemon.MaxHealth.Returns(maxHealth);
return pokemon;
}
/// <summary>
/// Helper to count the Damage calls a Pokémon received.
/// </summary>
private static int CountDamageCalls(IPokemon pokemon) =>
pokemon.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "Damage");
/// <summary>
/// Bulbapedia: "the ally moving second will use Fire Pledge" when Fire Pledge and Grass Pledge are
/// combined. The marked choice's move is replaced with Fire Pledge.
/// </summary>
[Test]
public async Task ChangeMove_CombinedMove_ExecutesFirePledge()
{
// Arrange
var (script, _, _, _, _) = CreateTestSetup();
var choice = Substitute.For<IMoveChoice>();
StringKey moveName = "grass_pledge";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("fire_pledge"));
}
/// <summary>
/// Bulbapedia: "the ally moving second will use Fire Pledge with a power of 150".
/// </summary>
[Test]
public async Task ChangeBasePower_CombinedMove_PowerIs150()
{
// Arrange
var (script, move, target, _, _) = CreateTestSetup();
ushort basePower = 80;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)150);
}
/// <summary>
/// Bulbapedia: the combined move will "create a sea of fire on the target's side of the field for four
/// turns." The <see cref="SeaOfFireEffect"/> is added to the target's side.
/// </summary>
[Test]
public async Task OnSecondaryEffect_CombinedMoveHits_AddsSeaOfFireToTargetsSide()
{
// Arrange
var (script, move, target, _, sideVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideVolatile.Get<SeaOfFireEffect>()).IsNotNull();
}
/// <summary>
/// Technical test: if the target has no <see cref="IPokemon.BattleData"/>, no sea of fire is created
/// and the script does not throw.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHasNoBattleData_DoesNothing()
{
// Arrange
var (script, move, target, _, sideVolatile) = CreateTestSetup();
target.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideVolatile.Count).IsEqualTo(0);
}
/// <summary>
/// Bulbapedia: "The sea of fire damages all non-Fire Pokémon on that side of the field for 1/8 of
/// their maximum HP at the end of each turn."
/// </summary>
[Test, Arguments(96u, 12u), Arguments(100u, 12u), Arguments(120u, 15u)]
public async Task SeaOfFireEffect_OnEndTurn_DamagesNonFirePokemonForOneEighthOfMaxHp(uint maxHealth,
uint expectedDamage)
{
// Arrange
var (script, move, target, side, sideVolatile) = CreateTestSetup();
var waterPokemon = CreateSidePokemon("water", maxHealth);
side.Pokemon.Returns(new List<IPokemon?> { waterPokemon });
script.OnSecondaryEffect(move, target, 0);
var seaOfFire = sideVolatile.Get<SeaOfFireEffect>()!;
// Act
seaOfFire.OnEndTurn(side, Substitute.For<IBattle>());
// Assert
var call = waterPokemon.ReceivedCalls().First(c => c.GetMethodInfo().Name == "Damage");
await Assert.That((uint)call.GetArguments()[0]!).IsEqualTo(expectedDamage);
await Assert.That((DamageSource)call.GetArguments()[1]!).IsEqualTo(DamageSource.Misc);
}
/// <summary>
/// Bulbapedia: the sea of fire damages "all non-Fire Pokémon on that side of the field" — Fire-type
/// Pokémon are not hurt by it.
/// </summary>
[Test]
public async Task SeaOfFireEffect_OnEndTurn_DoesNotDamageFirePokemon()
{
// Arrange
var (script, move, target, side, sideVolatile) = CreateTestSetup();
var firePokemon = CreateSidePokemon("fire", 100);
side.Pokemon.Returns(new List<IPokemon?> { firePokemon });
script.OnSecondaryEffect(move, target, 0);
var seaOfFire = sideVolatile.Get<SeaOfFireEffect>()!;
// Act
seaOfFire.OnEndTurn(side, Substitute.For<IBattle>());
// Assert
await Assert.That(CountDamageCalls(firePokemon)).IsEqualTo(0);
}
/// <summary>
/// Bulbapedia: the sea of fire lasts "for four turns", damaging "at the end of each turn." Over any
/// number of end-of-turns, a non-Fire Pokémon on the covered side is damaged exactly four times.
/// </summary>
[Test]
public async Task SeaOfFireEffect_LastsFourTurns_DamagesExactlyFourTimes()
{
// Arrange
var (script, move, target, side, sideVolatile) = CreateTestSetup();
var waterPokemon = CreateSidePokemon("water", 96);
side.Pokemon.Returns(new List<IPokemon?> { waterPokemon });
script.OnSecondaryEffect(move, target, 0);
var seaOfFire = sideVolatile.Get<SeaOfFireEffect>()!;
var battle = Substitute.For<IBattle>();
// Act - more end-of-turns than the sea of fire lasts
for (var i = 0; i < 6; i++)
seaOfFire.OnEndTurn(side, battle);
// Assert
await Assert.That(CountDamageCalls(waterPokemon)).IsEqualTo(4);
}
/// <summary>
/// Technical test: once its duration has run out, the sea of fire removes itself from the side's
/// volatile scripts.
/// </summary>
[Test]
public async Task SeaOfFireEffect_AfterExpiry_HasRemovedItselfFromSide()
{
// Arrange
var (script, move, target, side, sideVolatile) = CreateTestSetup();
side.Pokemon.Returns(new List<IPokemon?>());
script.OnSecondaryEffect(move, target, 0);
var seaOfFire = sideVolatile.Get<SeaOfFireEffect>()!;
var battle = Substitute.For<IBattle>();
// Act
for (var i = 0; i < 5; i++)
seaOfFire.OnEndTurn(side, battle);
// Assert
await Assert.That(sideVolatile.Contains("sea_of_fire_effect")).IsFalse();
}
}
@@ -0,0 +1,180 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.MoveVolatile;
using PkmnLib.Plugin.Gen7.Scripts.Side;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.MoveVolatile;
/// <summary>
/// Tests for the <see cref="FireWaterPledgeMove"/> volatile script, which implements the combined move of
/// Fire Pledge and Water Pledge, and the <see cref="RainbowEffect"/> it creates.
/// Bulbapedia (Fire Pledge): "If Fire Pledge and Water Pledge are used in the same turn, the ally moving
/// second will use Water Pledge with a power of 150, and create a rainbow on the user's side of the field
/// for four turns."
/// </summary>
public class FireWaterPledgeMoveTests
{
/// <summary>
/// Creates a fully mocked test setup. The user's battle side gets a real <see cref="ScriptSet"/> as its
/// volatile script set, so the rainbow created by the combined move can be inspected and driven.
/// </summary>
private static (FireWaterPledgeMove script, IExecutingMove move, IPokemon user, IBattleSide side, IScriptSet
sideVolatile) CreateTestSetup()
{
var script = new FireWaterPledgeMove();
var move = Substitute.For<IExecutingMove>();
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var sideVolatile = new ScriptSet(side);
side.VolatileScripts.Returns(sideVolatile);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.BattleSide.Returns(side);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
move.User.Returns(user);
return (script, move, user, side, sideVolatile);
}
/// <summary>
/// Bulbapedia: "the ally moving second will use Water Pledge" when Fire Pledge and Water Pledge are
/// combined. The marked choice's move is replaced with Water Pledge.
/// </summary>
[Test]
public async Task ChangeMove_CombinedMove_ExecutesWaterPledge()
{
// Arrange
var (script, _, _, _, _) = CreateTestSetup();
var choice = Substitute.For<IMoveChoice>();
StringKey moveName = "fire_pledge";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("water_pledge"));
}
/// <summary>
/// Bulbapedia: "the ally moving second will use Water Pledge with a power of 150".
/// </summary>
[Test]
public async Task ChangeBasePower_CombinedMove_PowerIs150()
{
// Arrange
var (script, move, _, _, _) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
ushort basePower = 80;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)150);
}
/// <summary>
/// Bulbapedia: the combined move will "create a rainbow on the user's side of the field for four turns."
/// The <see cref="RainbowEffect"/> is added to the user's side.
/// </summary>
[Test]
public async Task OnSecondaryEffect_CombinedMoveHits_AddsRainbowToUsersSide()
{
// Arrange
var (script, move, _, _, sideVolatile) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideVolatile.Get<RainbowEffect>()).IsNotNull();
}
/// <summary>
/// Technical test: if the user has no <see cref="IPokemon.BattleData"/>, no rainbow is created and the
/// script does not throw.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserHasNoBattleData_DoesNothing()
{
// Arrange
var (script, move, user, _, sideVolatile) = CreateTestSetup();
user.BattleData.Returns((IPokemonBattleData?)null);
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideVolatile.Count).IsEqualTo(0);
}
/// <summary>
/// Bulbapedia: "The rainbow doubles the probability of additional effects taking place for moves used
/// by that side of the field".
/// </summary>
[Test, Arguments(10f, 20f), Arguments(30f, 60f), Arguments(50f, 100f)]
public async Task RainbowEffect_ChangeEffectChance_DoublesTheChance(float chance, float expected)
{
// Arrange
var (script, move, _, _, sideVolatile) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
script.OnSecondaryEffect(move, target, 0);
var rainbow = sideVolatile.Get<RainbowEffect>()!;
// Act
rainbow.ChangeEffectChance(move, target, 0, ref chance);
// Assert
await Assert.That(chance).IsEqualTo(expected);
}
/// <summary>
/// Bulbapedia: the rainbow is created "on the user's side of the field for four turns." After the
/// fourth end-of-turn since its creation the rainbow must be gone.
/// </summary>
[Test]
public async Task RainbowEffect_AfterFourEndOfTurns_RainbowHasFaded()
{
// Arrange
var (script, move, _, side, sideVolatile) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
script.OnSecondaryEffect(move, target, 0);
var rainbow = sideVolatile.Get<RainbowEffect>()!;
var battle = Substitute.For<IBattle>();
// Act - four end-of-turn ticks, counting the turn the rainbow was created
for (var i = 0; i < 4; i++)
rainbow.OnEndTurn(side, battle);
// Assert
await Assert.That(sideVolatile.Contains("rainbow_effect")).IsFalse();
}
/// <summary>
/// Technical test: the rainbow does not stay around forever; it removes itself from the side once its
/// counter runs out (currently after the fifth end-of-turn).
/// </summary>
[Test]
public async Task RainbowEffect_AfterFiveEndOfTurns_HasRemovedItself()
{
// Arrange
var (script, move, _, side, sideVolatile) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
script.OnSecondaryEffect(move, target, 0);
var rainbow = sideVolatile.Get<RainbowEffect>()!;
var battle = Substitute.For<IBattle>();
// Act
for (var i = 0; i < 5; i++)
rainbow.OnEndTurn(side, battle);
// Assert
await Assert.That(sideVolatile.Contains("rainbow_effect")).IsFalse();
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -0,0 +1,113 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Flail"/> move script.
/// Gen VII Bulbapedia behavior: "Flail inflicts damage and has no additional effect. Its power is based on
/// the user's current HP as a percentage of its maximum HP, which is higher the lower the user's HP is."
/// The power tiers (all generations except IV) are: 20 power at HP ≥ 68.8%, 40 at 35.4% ≤ HP &lt; 68.8%,
/// 80 at 20.8% ≤ HP &lt; 35.4%, 100 at 10.4% ≤ HP &lt; 20.8%, 150 at 4.2% ≤ HP &lt; 10.4%, and
/// 200 at HP &lt; 4.2%. These percentages correspond to the tiers of floor(48 × current HP / max HP).
/// </summary>
public class FlailTests
{
/// <summary>
/// Creates a fully mocked test setup for Flail tests with the user at the given current and maximum HP.
/// </summary>
private static (Flail script, IExecutingMove move, IPokemon target) CreateTestSetup(uint currentHp, uint maxHp)
{
var script = new Flail();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var user = Substitute.For<IPokemon>();
user.CurrentHealth.Returns(currentHp);
user.BoostedStats.Returns(new StatisticSet<uint>(maxHp, 0, 0, 0, 0, 0));
move.User.Returns(user);
return (script, move, target);
}
/// <summary>
/// Bulbapedia: Flail's power is 20 when the user's HP is at least 68.8% of its maximum.
/// A user at full health uses the weakest power tier.
/// </summary>
[Test]
public async Task ChangeBasePower_UserAtFullHealth_PowerIs20()
{
// Arrange
var (script, move, target) = CreateTestSetup(100, 100);
ushort basePower = 20;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)20);
}
/// <summary>
/// Bulbapedia: Flail's power is 200 when the user's HP is below 4.2% of its maximum.
/// A user at 1 HP uses the strongest power tier.
/// </summary>
[Test]
public async Task ChangeBasePower_UserAtOneHp_PowerIs200()
{
// Arrange
var (script, move, target) = CreateTestSetup(1, 100);
ushort basePower = 20;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)200);
}
/// <summary>
/// Bulbapedia: "Its power is based on the user's current HP as a percentage of its maximum HP, which is
/// higher the lower the user's HP is". These rows cover the extremes of the table: full health
/// (HP ≥ 68.8% → 20 power) and nearly fainted (HP &lt; 4.2% → 200 power).
/// </summary>
[Test, Arguments(100u, 100u, 20), Arguments(4u, 100u, 200), Arguments(1u, 100u, 200)]
// 48/48 -> 20
// floor(1.92) = 1 -> 200
// floor(0.48) = 0 -> 200
public async Task ChangeBasePower_HpAtExtremes_MatchesFlailPowerTable(uint currentHp, uint maxHp, int expectedPower)
{
// Arrange
var (script, move, target) = CreateTestSetup(currentHp, maxHp);
ushort basePower = 20;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)expectedPower);
}
/// <summary>
/// Bulbapedia: "Its power is based on the user's current HP as a percentage of its maximum HP, which is
/// higher the lower the user's HP is", following the tiers 20 (HP ≥ 68.8%), 40 (35.4% ≤ HP &lt; 68.8%),
/// 80 (20.8% ≤ HP &lt; 35.4%), 100 (10.4% ≤ HP &lt; 20.8%), 150 (4.2% ≤ HP &lt; 10.4%) and 200 (HP &lt; 4.2%).
/// The rows cover both boundary values of every tier, verifying the integer arithmetic of
/// floor(48 × current HP / max HP).
/// </summary>
[Test, Arguments(69u, 100u, 20), Arguments(68u, 100u, 40), Arguments(36u, 100u, 40), Arguments(35u, 100u, 80),
Arguments(21u, 100u, 80), Arguments(20u, 100u, 100), Arguments(11u, 100u, 100), Arguments(10u, 100u, 150),
Arguments(5u, 100u, 150), Arguments(150u, 300u, 40), Arguments(24u, 48u, 40), Arguments(7u, 48u, 100)]
public async Task ChangeBasePower_HpFractionOfMax_MatchesFlailPowerTable(uint currentHp, uint maxHp,
int expectedPower)
{
// Arrange
var (script, move, target) = CreateTestSetup(currentHp, maxHp);
ushort basePower = 20;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)expectedPower);
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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>(),