204 files changed
+20255
-242
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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 < 68.8%,
|
||||
/// 80 at 20.8% ≤ HP < 35.4%, 100 at 10.4% ≤ HP < 20.8%, 150 at 4.2% ≤ HP < 10.4%, and
|
||||
/// 200 at HP < 4.2%. These percentages correspond to the tiers of floor(48 × current HP / max HP).
|
||||
/// </summary>
|
||||
public class FlailTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a fully mocked test setup for Flail tests with the user at the given current and maximum HP.
|
||||
/// </summary>
|
||||
private static (Flail script, IExecutingMove move, IPokemon target) CreateTestSetup(uint currentHp, uint maxHp)
|
||||
{
|
||||
var script = new Flail();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
var user = Substitute.For<IPokemon>();
|
||||
user.CurrentHealth.Returns(currentHp);
|
||||
user.BoostedStats.Returns(new StatisticSet<uint>(maxHp, 0, 0, 0, 0, 0));
|
||||
move.User.Returns(user);
|
||||
return (script, move, target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: Flail's power is 20 when the user's HP is at least 68.8% of its maximum.
|
||||
/// A user at full health uses the weakest power tier.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ChangeBasePower_UserAtFullHealth_PowerIs20()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target) = CreateTestSetup(100, 100);
|
||||
ushort basePower = 20;
|
||||
|
||||
// Act
|
||||
script.ChangeBasePower(move, target, 0, ref basePower);
|
||||
|
||||
// Assert
|
||||
await Assert.That(basePower).IsEqualTo((ushort)20);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: Flail's power is 200 when the user's HP is below 4.2% of its maximum.
|
||||
/// A user at 1 HP uses the strongest power tier.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ChangeBasePower_UserAtOneHp_PowerIs200()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target) = CreateTestSetup(1, 100);
|
||||
ushort basePower = 20;
|
||||
|
||||
// Act
|
||||
script.ChangeBasePower(move, target, 0, ref basePower);
|
||||
|
||||
// Assert
|
||||
await Assert.That(basePower).IsEqualTo((ushort)200);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Its power is based on the user's current HP as a percentage of its maximum HP, which is
|
||||
/// higher the lower the user's HP is". These rows cover the extremes of the table: full health
|
||||
/// (HP ≥ 68.8% → 20 power) and nearly fainted (HP < 4.2% → 200 power).
|
||||
/// </summary>
|
||||
[Test, Arguments(100u, 100u, 20), Arguments(4u, 100u, 200), Arguments(1u, 100u, 200)]
|
||||
// 48/48 -> 20
|
||||
// floor(1.92) = 1 -> 200
|
||||
// floor(0.48) = 0 -> 200
|
||||
public async Task ChangeBasePower_HpAtExtremes_MatchesFlailPowerTable(uint currentHp, uint maxHp, int expectedPower)
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target) = CreateTestSetup(currentHp, maxHp);
|
||||
ushort basePower = 20;
|
||||
|
||||
// Act
|
||||
script.ChangeBasePower(move, target, 0, ref basePower);
|
||||
|
||||
// Assert
|
||||
await Assert.That(basePower).IsEqualTo((ushort)expectedPower);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Its power is based on the user's current HP as a percentage of its maximum HP, which is
|
||||
/// higher the lower the user's HP is", following the tiers 20 (HP ≥ 68.8%), 40 (35.4% ≤ HP < 68.8%),
|
||||
/// 80 (20.8% ≤ HP < 35.4%), 100 (10.4% ≤ HP < 20.8%), 150 (4.2% ≤ HP < 10.4%) and 200 (HP < 4.2%).
|
||||
/// The rows cover both boundary values of every tier, verifying the integer arithmetic of
|
||||
/// floor(48 × current HP / max HP).
|
||||
/// </summary>
|
||||
[Test, Arguments(69u, 100u, 20), Arguments(68u, 100u, 40), Arguments(36u, 100u, 40), Arguments(35u, 100u, 80),
|
||||
Arguments(21u, 100u, 80), Arguments(20u, 100u, 100), Arguments(11u, 100u, 100), Arguments(10u, 100u, 150),
|
||||
Arguments(5u, 100u, 150), Arguments(150u, 300u, 40), Arguments(24u, 48u, 40), Arguments(7u, 48u, 100)]
|
||||
public async Task ChangeBasePower_HpFractionOfMax_MatchesFlailPowerTable(uint currentHp, uint maxHp,
|
||||
int expectedPower)
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target) = CreateTestSetup(currentHp, maxHp);
|
||||
ushort basePower = 20;
|
||||
|
||||
// Act
|
||||
script.ChangeBasePower(move, target, 0, ref basePower);
|
||||
|
||||
// Assert
|
||||
await Assert.That(basePower).IsEqualTo((ushort)expectedPower);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using PkmnLib.Dynamic.Models;
|
||||
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||
using PkmnLib.Static;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="FlameBurst"/> move script.
|
||||
/// Gen V–VII Bulbapedia behavior: "Flame Burst deals damage to the target, then inflicts damage to up to two
|
||||
/// Pokémon adjacent to that target (including the user if targeting an ally) equal to 1/16 of their
|
||||
/// respective maximum HP." The splash damage is classified as effect damage rather than move damage.
|
||||
/// </summary>
|
||||
public class FlameBurstTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a mocked battle Pokémon that lives in <paramref name="battle"/> on the given side and position.
|
||||
/// </summary>
|
||||
private static IPokemon CreateBattlePokemon(IBattle battle, byte sideIndex, byte position, uint maxHp = 100)
|
||||
{
|
||||
var pokemon = Substitute.For<IPokemon>();
|
||||
var battleData = Substitute.For<IPokemonBattleData>();
|
||||
battleData.Battle.Returns(battle);
|
||||
battleData.SideIndex.Returns(sideIndex);
|
||||
battleData.Position.Returns(position);
|
||||
pokemon.BattleData.Returns(battleData);
|
||||
pokemon.BoostedStats.Returns(new StatisticSet<uint>(maxHp, 0, 0, 0, 0, 0));
|
||||
pokemon.MaxHealth.Returns(maxHp);
|
||||
return pokemon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the two sides of <paramref name="battle"/> with the given Pokémon lists.
|
||||
/// </summary>
|
||||
private static void SetSides(IBattle battle, List<IPokemon?> side0Pokemon, List<IPokemon?> side1Pokemon)
|
||||
{
|
||||
var side0 = Substitute.For<IBattleSide>();
|
||||
side0.Pokemon.Returns(side0Pokemon);
|
||||
var side1 = Substitute.For<IBattleSide>();
|
||||
side1.Pokemon.Returns(side1Pokemon);
|
||||
battle.Sides.Returns(new[] { side0, side1 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fully mocked double battle: the user and its ally on side 0, the target and its ally on
|
||||
/// side 1. Flame Burst is used by the user against the target.
|
||||
/// </summary>
|
||||
private static (FlameBurst script, IExecutingMove move, IPokemon user, IPokemon userAlly, IPokemon target, IPokemon
|
||||
targetAlly) CreateDoubleBattleSetup(uint targetAllyMaxHp = 160)
|
||||
{
|
||||
var script = new FlameBurst();
|
||||
var battle = Substitute.For<IBattle>();
|
||||
battle.PositionsPerSide.Returns((byte)2);
|
||||
|
||||
var user = CreateBattlePokemon(battle, 0, 0);
|
||||
var userAlly = CreateBattlePokemon(battle, 0, 1);
|
||||
var target = CreateBattlePokemon(battle, 1, 0);
|
||||
var targetAlly = CreateBattlePokemon(battle, 1, 1, targetAllyMaxHp);
|
||||
SetSides(battle, [user, userAlly], [target, targetAlly]);
|
||||
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
move.User.Returns(user);
|
||||
|
||||
return (script, move, user, userAlly, target, targetAlly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to extract the damage amount from a Pokémon's received Damage calls, or 0 if none was received.
|
||||
/// </summary>
|
||||
private static uint GetDamageAmount(IPokemon pokemon)
|
||||
{
|
||||
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
|
||||
return call != null ? (uint)call.GetArguments()[0]! : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to extract the damage source from a Pokémon's received Damage calls.
|
||||
/// </summary>
|
||||
private static DamageSource? GetDamageSource(IPokemon pokemon)
|
||||
{
|
||||
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
|
||||
return call != null ? (DamageSource)call.GetArguments()[1]! : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper that checks whether a Pokémon received any Damage call.
|
||||
/// </summary>
|
||||
private static bool WasDamaged(IPokemon pokemon) =>
|
||||
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage");
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Flame Burst deals damage to the target, then inflicts damage to up to two Pokémon adjacent
|
||||
/// to that target ... equal to 1/16 of their respective maximum HP."
|
||||
/// In a double battle, the target's ally is adjacent to the target and takes 1/16 of its own maximum HP.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_DoubleBattle_TargetAllyTakesOneSixteenthOfItsMaxHp()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, _, _, target, targetAlly) = CreateDoubleBattleSetup(160);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert - 160 / 16 = 10
|
||||
await Assert.That(GetDamageAmount(targetAlly)).IsEqualTo(10u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: the splash damage is "equal to 1/16 of their respective maximum HP".
|
||||
/// Tests various maximum HP values to ensure proper integer truncation.
|
||||
/// </summary>
|
||||
[Test, Arguments(160u, 10u), Arguments(100u, 6u), Arguments(32u, 2u), Arguments(15u, 0u)]
|
||||
public async Task OnSecondaryEffect_DoubleBattle_SplashDamageCalculation(uint allyMaxHp, uint expectedDamage)
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, _, _, target, targetAlly) = CreateDoubleBattleSetup(allyMaxHp);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(GetDamageAmount(targetAlly)).IsEqualTo(expectedDamage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: the splash damage hits Pokémon "adjacent to that target". The user's own ally is not
|
||||
/// adjacent to the target and must not be damaged when targeting an opponent.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_DoubleBattle_UserAllyIsNotDamaged()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, _, userAlly, target, _) = CreateDoubleBattleSetup();
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(WasDamaged(userAlly)).IsFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "this extra damage is classified as effect damage", so the splash damage is dealt with
|
||||
/// <see cref="DamageSource.Misc"/> rather than move damage.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_DoubleBattle_SplashDamageUsesMiscDamageSource()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, _, _, target, targetAlly) = CreateDoubleBattleSetup();
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(GetDamageSource(targetAlly)!.Value).IsEqualTo(DamageSource.Misc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "inflicts damage to up to two Pokémon adjacent to that target".
|
||||
/// In a triple battle with the target in the middle, both of the target's allies are adjacent and both
|
||||
/// take splash damage.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_TripleBattleTargetInMiddle_BothAdjacentAlliesDamaged()
|
||||
{
|
||||
// Arrange
|
||||
var script = new FlameBurst();
|
||||
var battle = Substitute.For<IBattle>();
|
||||
battle.PositionsPerSide.Returns((byte)3);
|
||||
|
||||
var user = CreateBattlePokemon(battle, 0, 1);
|
||||
var target = CreateBattlePokemon(battle, 1, 1);
|
||||
var allyLeft = CreateBattlePokemon(battle, 1, 0, 96);
|
||||
var allyRight = CreateBattlePokemon(battle, 1, 2, 64);
|
||||
SetSides(battle, [null, user, null], [allyLeft, target, allyRight]);
|
||||
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
move.User.Returns(user);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert - 96 / 16 = 6 and 64 / 16 = 4
|
||||
await Assert.That(GetDamageAmount(allyLeft)).IsEqualTo(6u);
|
||||
await Assert.That(GetDamageAmount(allyRight)).IsEqualTo(4u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: only Pokémon "adjacent to that target" take splash damage.
|
||||
/// In a triple battle with the target at the edge, the ally two positions away is not adjacent and takes
|
||||
/// no damage.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_TripleBattleTargetAtEdge_NonAdjacentAllyNotDamaged()
|
||||
{
|
||||
// Arrange
|
||||
var script = new FlameBurst();
|
||||
var battle = Substitute.For<IBattle>();
|
||||
battle.PositionsPerSide.Returns((byte)3);
|
||||
|
||||
var user = CreateBattlePokemon(battle, 0, 0);
|
||||
var target = CreateBattlePokemon(battle, 1, 0);
|
||||
var allyMiddle = CreateBattlePokemon(battle, 1, 1, 160);
|
||||
var allyFar = CreateBattlePokemon(battle, 1, 2, 160);
|
||||
SetSides(battle, [user, null, null], [target, allyMiddle, allyFar]);
|
||||
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
move.User.Returns(user);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert - only the adjacent ally is hit
|
||||
await Assert.That(GetDamageAmount(allyMiddle)).IsEqualTo(10u);
|
||||
await Assert.That(WasDamaged(allyFar)).IsFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: the splash damage hits Pokémon "adjacent to that target". In a single battle there are no
|
||||
/// Pokémon adjacent to the target, so nothing else is damaged.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_SingleBattle_NoSplashDamage()
|
||||
{
|
||||
// Arrange
|
||||
var script = new FlameBurst();
|
||||
var battle = Substitute.For<IBattle>();
|
||||
battle.PositionsPerSide.Returns((byte)1);
|
||||
|
||||
var user = CreateBattlePokemon(battle, 0, 0);
|
||||
var target = CreateBattlePokemon(battle, 1, 0);
|
||||
SetSides(battle, [user], [target]);
|
||||
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
move.User.Returns(user);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert - neither the user nor the target receives effect damage
|
||||
await Assert.That(WasDamaged(user)).IsFalse();
|
||||
await Assert.That(WasDamaged(target)).IsFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: an empty adjacent position (a fainted or absent ally) does not cause an exception and
|
||||
/// simply receives no splash damage.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_AdjacentPositionEmpty_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var script = new FlameBurst();
|
||||
var battle = Substitute.For<IBattle>();
|
||||
battle.PositionsPerSide.Returns((byte)2);
|
||||
|
||||
var user = CreateBattlePokemon(battle, 0, 0);
|
||||
var userAlly = CreateBattlePokemon(battle, 0, 1);
|
||||
var target = CreateBattlePokemon(battle, 1, 0);
|
||||
SetSides(battle, [user, userAlly], [target, null]);
|
||||
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
move.User.Returns(user);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.That(() => script.OnSecondaryEffect(move, target, 0)).ThrowsNothing();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: when no battle data is available the script does nothing instead of throwing.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_NoBattleData_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var script = new FlameBurst();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var user = Substitute.For<IPokemon>();
|
||||
user.BattleData.Returns((IPokemonBattleData?)null);
|
||||
move.User.Returns(user);
|
||||
var target = Substitute.For<IPokemon>();
|
||||
target.BattleData.Returns((IPokemonBattleData?)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.That(() => script.OnSecondaryEffect(move, target, 0)).ThrowsNothing();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using PkmnLib.Dynamic.Models;
|
||||
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||
using PkmnLib.Static.Utils;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="FlameWheel"/> move script.
|
||||
/// Gen VII Bulbapedia behavior: "Flame Wheel will thaw out the user if it is frozen, and then inflict damage
|
||||
/// on the target. Flame Wheel also has a 10% chance of burning the target."
|
||||
/// </summary>
|
||||
public class FlameWheelTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a fully mocked test setup for Flame Wheel tests, initialized with the given burn chance.
|
||||
/// </summary>
|
||||
private static (FlameWheel script, IExecutingMove move, IPokemon user, IPokemon target, IBattleRandom random)
|
||||
CreateTestSetup(float burnChance = 10f)
|
||||
{
|
||||
var script = new FlameWheel();
|
||||
script.OnInitialize(new Dictionary<StringKey, object?> { { "burn_chance", burnChance } });
|
||||
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var user = Substitute.For<IPokemon>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
|
||||
var battle = Substitute.For<IBattle>();
|
||||
var random = Substitute.For<IBattleRandom>();
|
||||
battle.Random.Returns(random);
|
||||
move.Battle.Returns(battle);
|
||||
move.User.Returns(user);
|
||||
|
||||
return (script, move, user, target, random);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper that checks whether a Pokémon received a SetStatus call for the given status.
|
||||
/// </summary>
|
||||
private static bool ReceivedSetStatus(IPokemon pokemon, string status) =>
|
||||
pokemon.ReceivedCalls().Any(c =>
|
||||
c.GetMethodInfo().Name == "SetStatus" && (StringKey)c.GetArguments()[0]! == new StringKey(status));
|
||||
|
||||
/// <summary>
|
||||
/// Helper that checks whether a Pokémon received a ClearStatus call.
|
||||
/// </summary>
|
||||
private static bool ReceivedClearStatus(IPokemon pokemon) =>
|
||||
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ClearStatus");
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Flame Wheel also has a 10% chance of burning the target."
|
||||
/// When the effect chance roll succeeds, the target is burned by the user.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_EffectChanceSucceeds_BurnsTarget()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, _, target, random) = CreateTestSetup();
|
||||
random.EffectChance(10f, move, target, 0).Returns(true);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(ReceivedSetStatus(target, "burned")).IsTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Flame Wheel also has a 10% chance of burning the target."
|
||||
/// When the effect chance roll fails, the target is not burned.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_EffectChanceFails_DoesNotBurnTarget()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, _, target, random) = CreateTestSetup();
|
||||
random.EffectChance(10f, move, target, 0).Returns(false);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(ReceivedSetStatus(target, "burned")).IsFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Flame Wheel also has a 10% chance of burning the target."
|
||||
/// The configured burn chance is the one rolled against, and it is rolled through the battle's
|
||||
/// <see cref="IBattleRandom.EffectChance"/> so that effect-chance-modifying effects apply.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_ConfiguredBurnChance_IsPassedToEffectChanceRoll()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, _, target, random) = CreateTestSetup(30f);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
random.Received(1).EffectChance(30f, move, target, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Flame Wheel will thaw out the user if it is frozen".
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_UserFrozen_UserIsThawed()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, user, target, _) = CreateTestSetup();
|
||||
user.HasStatus("frozen").Returns(true);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(ReceivedClearStatus(user)).IsTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Flame Wheel will thaw out the user if it is frozen". A user that is not frozen keeps
|
||||
/// whatever status it has; the script must not clear it.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_UserNotFrozen_StatusIsNotCleared()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, user, target, _) = CreateTestSetup();
|
||||
user.HasStatus("frozen").Returns(false);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(ReceivedClearStatus(user)).IsFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: initializing without parameters is invalid, as the script requires a burn chance.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnInitialize_NullParameters_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var script = new FlameWheel();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.That(() => script.OnInitialize(null)).ThrowsExactly<Exception>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: initializing without a "burn_chance" parameter is invalid.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnInitialize_MissingBurnChance_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var script = new FlameWheel();
|
||||
var parameters = new Dictionary<StringKey, object?> { { "unrelated", 10f } };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.That(() => script.OnInitialize(parameters)).ThrowsExactly<Exception>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Flame Wheel also has a 10% chance of burning the target."
|
||||
/// Integration check: initializing the script with Flame Wheel's actual effect parameters from the Gen7
|
||||
/// data results in a 10% burn chance being rolled.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_InitializedWithFlameWheelData_UsesTenPercentBurnChance()
|
||||
{
|
||||
// Arrange
|
||||
var library = LibraryHelpers.LoadLibrary();
|
||||
await Assert.That(library.StaticLibrary.Moves.TryGet("flame_wheel", out var flameWheel)).IsTrue();
|
||||
var (script, move, _, target, random) = CreateTestSetup();
|
||||
|
||||
// Act
|
||||
script.OnInitialize(flameWheel!.SecondaryEffect!.Parameters);
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
random.Received(1).EffectChance(10f, move, target, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using PkmnLib.Dynamic.Models;
|
||||
using PkmnLib.Dynamic.ScriptHandling;
|
||||
using PkmnLib.Plugin.Gen7.Scripts.Abilities;
|
||||
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||
using PkmnLib.Static.Utils;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="FlareBlitz"/> move script.
|
||||
/// Gen VII Bulbapedia behavior: "Flare Blitz inflicts damage, and the user receives recoil damage equal to
|
||||
/// ⅓ of the damage done to the target. This move has a 10% chance of burning the target."
|
||||
/// </summary>
|
||||
public class FlareBlitzTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a fully mocked test setup for Flare Blitz tests, where the hit dealt
|
||||
/// <paramref name="damage"/> damage to the target.
|
||||
/// </summary>
|
||||
private static (FlareBlitz script, IExecutingMove move, IPokemon user, IPokemon target, IBattleRandom random)
|
||||
CreateTestSetup(uint damage, Script[]? moveScripts = null)
|
||||
{
|
||||
var script = new FlareBlitz();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var user = Substitute.For<IPokemon>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
|
||||
var hitData = Substitute.For<IHitData>();
|
||||
hitData.Damage.Returns(damage);
|
||||
move.GetHitData(target, 0).Returns(hitData);
|
||||
|
||||
var battle = Substitute.For<IBattle>();
|
||||
var random = Substitute.For<IBattleRandom>();
|
||||
battle.Random.Returns(random);
|
||||
var battleData = Substitute.For<IPokemonBattleData>();
|
||||
battleData.Battle.Returns(battle);
|
||||
target.BattleData.Returns(battleData);
|
||||
|
||||
// RunScriptHook iterates the move's scripts; give the mock a real iterator so the hook pass runs
|
||||
// (empty unless the test attaches scripts such as Rock Head).
|
||||
var containers = (moveScripts ?? []).Select(IEnumerable<ScriptContainer> (s) => new ScriptContainer(s))
|
||||
.ToArray();
|
||||
move.GetScripts().Returns(_ => new ScriptIterator(containers));
|
||||
|
||||
move.User.Returns(user);
|
||||
|
||||
return (script, move, user, target, random);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to extract the damage amount from a Pokémon's received Damage calls, or 0 if none was received.
|
||||
/// </summary>
|
||||
private static uint GetDamageAmount(IPokemon pokemon)
|
||||
{
|
||||
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
|
||||
return call != null ? (uint)call.GetArguments()[0]! : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to extract the damage source from a Pokémon's received Damage calls.
|
||||
/// </summary>
|
||||
private static DamageSource? GetDamageSource(IPokemon pokemon)
|
||||
{
|
||||
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
|
||||
return call != null ? (DamageSource)call.GetArguments()[1]! : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper that checks whether a Pokémon received a SetStatus call for the given status.
|
||||
/// </summary>
|
||||
private static bool ReceivedSetStatus(IPokemon pokemon, string status) =>
|
||||
pokemon.ReceivedCalls().Any(c =>
|
||||
c.GetMethodInfo().Name == "SetStatus" && (StringKey)c.GetArguments()[0]! == new StringKey(status));
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "the user receives recoil damage equal to ⅓ of the damage done to the target."
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_DamageDealt_UserTakesOneThirdRecoil()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, user, target, _) = CreateTestSetup(90);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert - 90 / 3 = 30
|
||||
await Assert.That(GetDamageAmount(user)).IsEqualTo(30u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "the user receives recoil damage equal to ⅓ of the damage done to the target."
|
||||
/// Tests various damage values to ensure proper integer truncation of the one-third amount.
|
||||
/// </summary>
|
||||
[Test, Arguments(90u, 30u), Arguments(100u, 33u), Arguments(120u, 40u), Arguments(2u, 0u), Arguments(301u, 100u)]
|
||||
public async Task OnSecondaryEffect_DamageDealt_RecoilCalculation(uint damage, uint expectedRecoil)
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, user, target, _) = CreateTestSetup(damage);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(GetDamageAmount(user)).IsEqualTo(expectedRecoil);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "the user receives recoil damage". Recoil is indirect damage, so it is dealt with
|
||||
/// <see cref="DamageSource.Misc"/> rather than move damage.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_DamageDealt_RecoilUsesMiscDamageSource()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, user, target, _) = CreateTestSetup(90);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(GetDamageSource(user)!.Value).IsEqualTo(DamageSource.Misc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "This move has a 10% chance of burning the target."
|
||||
/// When the effect chance roll succeeds, the target is burned.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_EffectChanceSucceeds_BurnsTarget()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, _, target, random) = CreateTestSetup(90);
|
||||
random.EffectChance(10f, move, target, 0).Returns(true);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(ReceivedSetStatus(target, "burned")).IsTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "This move has a 10% chance of burning the target."
|
||||
/// When the effect chance roll fails, the target is not burned.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_EffectChanceFails_DoesNotBurnTarget()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, _, target, random) = CreateTestSetup(90);
|
||||
random.EffectChance(10f, move, target, 0).Returns(false);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(ReceivedSetStatus(target, "burned")).IsFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "This move has a 10% chance of burning the target."
|
||||
/// The 10% chance is rolled through the battle's <see cref="IBattleRandom.EffectChance"/> so that
|
||||
/// effect-chance-modifying effects (such as Serene Grace) apply.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_DamageDealt_RollsTenPercentEffectChance()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, _, target, random) = CreateTestSetup(90);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
random.Received(1).EffectChance(10f, move, target, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The recoil can be prevented by recoil-negating effects (Bulbapedia: Rock Head "prevents the Pokémon
|
||||
/// from taking recoil damage from most moves"). When a script such as <see cref="RockHead"/> flags the
|
||||
/// recoil as prevented, the user takes no recoil damage.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_RecoilPrevented_UserTakesNoRecoilDamage()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, user, target, _) = CreateTestSetup(90, [new RockHead()]);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "This move has a 10% chance of burning the target." The burn chance is independent of the
|
||||
/// recoil: Rock Head only "prevents the Pokémon from taking recoil damage", so preventing the recoil must
|
||||
/// not suppress the burn chance.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_RecoilPrevented_BurnChanceStillApplies()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, _, target, random) = CreateTestSetup(90, [new RockHead()]);
|
||||
random.EffectChance(10f, move, target, 0).Returns(true);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(ReceivedSetStatus(target, "burned")).IsTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: when the target has no <see cref="IPokemon.BattleData"/>, the script does nothing;
|
||||
/// no recoil is taken and no burn is rolled.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_TargetHasNoBattleData_NoRecoilOrBurn()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, user, target, _) = CreateTestSetup(90);
|
||||
target.BattleData.Returns((IPokemonBattleData?)null);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse();
|
||||
await Assert.That(ReceivedSetStatus(target, "burned")).IsFalse();
|
||||
}
|
||||
}
|
||||
@@ -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>(), | ||||