More unit tests, fixes

This commit is contained in:
2026-07-05 13:01:12 +02:00
parent f9878e76ba
commit 16a1990486
23 changed files with 3204 additions and 13 deletions

View File

@@ -0,0 +1,283 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static.Moves;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="BanefulBunker"/> move script and its attached <see cref="BanefulBunkerEffect"/>.
/// Behavior is verified against the Bulbapedia page for Baneful Bunker (Generation VII).
/// </summary>
public class BanefulBunkerTests
{
/// <summary>
/// Creates a fully mocked setup for driving <see cref="BanefulBunker"/>'s inherited
/// <see cref="ProtectionScript.OnSecondaryEffect"/>. The <c>target</c> of the secondary effect is the
/// Pokémon using Baneful Bunker itself, as the move is self-targeted.
/// </summary>
private static (BanefulBunker script, IExecutingMove move, IPokemon target, IHitData hitData, IScriptSet volatileSet
) CreateProtectSetup(bool userMovesLast, float randomRoll)
{
var script = new BanefulBunker();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
// A queue with a remaining choice means another Pokémon still has to move after the user.
var queue = userMovesLast ? new BattleChoiceQueue([]) : new BattleChoiceQueue([Substitute.For<IMoveChoice>()]);
var random = Substitute.For<IBattleRandom>();
random.GetFloat().Returns(randomRoll);
var battle = Substitute.For<IBattle>();
battle.ChoiceQueue.Returns(queue);
battle.Random.Returns(random);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
target.BattleData.Returns(battleData);
// Give the mock a real script iterator (used by the volatile add hook) and a real volatile script set.
target.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet volatileSet = new ScriptSet(target);
target.Volatile.Returns(volatileSet);
return (script, move, target, hitData, volatileSet);
}
/// <summary>
/// Creates a fully mocked setup for driving <see cref="BanefulBunkerEffect.BlockIncomingHit"/>, the
/// volatile script that <see cref="BanefulBunker"/> attaches to its user.
/// </summary>
private static (BanefulBunkerEffect effect, IExecutingMove move, IPokemon target, IPokemon attacker)
CreateBlockSetup(bool isContact, bool hasProtectFlag, MoveCategory category = MoveCategory.Physical)
{
var effect = new BanefulBunkerEffect();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
hitData.IsContact.Returns(isContact);
move.GetHitData(target, 0).Returns(hitData);
var useMove = Substitute.For<IMoveData>();
useMove.HasFlag(new StringKey("protect")).Returns(hasProtectFlag);
useMove.Category.Returns(category);
move.UseMove.Returns(useMove);
var attacker = Substitute.For<IPokemon>();
attacker.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
move.User.Returns(attacker);
target.BattleData.Returns(Substitute.For<IPokemonBattleData>());
return (effect, move, target, attacker);
}
/// <summary>
/// Helper to extract the status name from a Pokémon's received <see cref="IPokemon.SetStatus"/> calls.
/// </summary>
private static string? GetStatusSet(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "SetStatus");
return call != null ? ((StringKey)call.GetArguments()[0]!).ToString() : null;
}
/// <summary>
/// Bulbapedia: "Baneful Bunker protects the user from all effects of moves that target it during the turn
/// it is used, including damage."
/// Using the move attaches the <see cref="BanefulBunkerEffect"/> volatile script to the user.
/// </summary>
[Test]
public async Task OnSecondaryEffect_FirstUse_AddsBanefulBunkerEffect()
{
// Arrange
var (script, move, target, hitData, volatileSet) = CreateProtectSetup(false, 0.0f);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Get<BanefulBunkerEffect>()).IsNotNull();
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: "If the user goes last in the turn, the move will fail."
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserMovesLast_Fails()
{
// Arrange
var (script, move, target, hitData, volatileSet) = CreateProtectSetup(true, 0.0f);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(volatileSet.Get<BanefulBunkerEffect>()).IsNull();
}
/// <summary>
/// Bulbapedia: "The chance that Baneful Bunker will succeed also drops each time the user successfully and
/// consecutively uses Endure, any protection move that only affects the user, Quick Guard, or Wide Guard."
/// A successful use registers a consecutive protection turn on the <see cref="ProtectionFailureScript"/>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_FirstUse_RegistersConsecutiveProtectionUse()
{
// Arrange
var (script, move, target, _, volatileSet) = CreateProtectSetup(false, 0.0f);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
var failure = volatileSet.Get<ProtectionFailureScript>();
await Assert.That(failure).IsNotNull();
await Assert.That(failure!.ProtectTurns).IsEqualTo(1);
await Assert.That(failure.UsedProtect).IsTrue();
}
/// <summary>
/// Bulbapedia: "Each time, the chance of success is divided by 3."
/// After one consecutive protection use the chance is 1/3, so a roll above 1/3 makes the move fail.
/// </summary>
[Test]
public async Task OnSecondaryEffect_SecondConsecutiveUse_RollAboveOneThird_Fails()
{
// Arrange
var (script, move, target, hitData, volatileSet) = CreateProtectSetup(false, 0.5f);
volatileSet.Add(new ProtectionFailureScript { ProtectTurns = 1 });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(volatileSet.Get<BanefulBunkerEffect>()).IsNull();
}
/// <summary>
/// Bulbapedia: "Each time, the chance of success is divided by 3."
/// After one consecutive protection use the chance is 1/3, so a roll below 1/3 still succeeds and
/// increments the consecutive use counter.
/// </summary>
[Test]
public async Task OnSecondaryEffect_SecondConsecutiveUse_RollBelowOneThird_Succeeds()
{
// Arrange
var (script, move, target, hitData, volatileSet) = CreateProtectSetup(false, 0.2f);
var failure = new ProtectionFailureScript { ProtectTurns = 1 };
volatileSet.Add(failure);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.DidNotReceive().Fail();
await Assert.That(volatileSet.Get<BanefulBunkerEffect>()).IsNotNull();
await Assert.That(failure.ProtectTurns).IsEqualTo(2);
}
/// <summary>
/// Bulbapedia: "Baneful Bunker protects the user from all effects of moves that target it during the turn
/// it is used, including damage."
/// A move that can be protected against (it has the protect flag) is blocked by the
/// <see cref="BanefulBunkerEffect"/>.
/// </summary>
[Test]
public async Task BlockIncomingHit_MoveWithProtectFlag_BlocksHit()
{
// Arrange
var (effect, move, target, _) = CreateBlockSetup(false, true);
var block = false;
// Act
effect.BlockIncomingHit(move, target, 0, ref block);
// Assert
await Assert.That(block).IsTrue();
}
/// <summary>
/// Bulbapedia: "Baneful Bunker protects the user from all effects of moves that target it"; moves that
/// bypass protection (they lack the protect flag) are not blocked by the <see cref="BanefulBunkerEffect"/>.
/// </summary>
[Test]
public async Task BlockIncomingHit_MoveWithoutProtectFlag_DoesNotBlock()
{
// Arrange
var (effect, move, target, _) = CreateBlockSetup(false, false);
var block = false;
// Act
effect.BlockIncomingHit(move, target, 0, ref block);
// Assert
await Assert.That(block).IsFalse();
}
/// <summary>
/// Bulbapedia: "If Baneful Bunker blocks an attack that would make contact with the user, the attacker
/// becomes poisoned (unless they are immune)."
/// </summary>
[Test]
public async Task BlockIncomingHit_BlockedContactMove_PoisonsAttacker()
{
// Arrange
var (effect, move, target, attacker) = CreateBlockSetup(true, true);
var block = false;
// Act
effect.BlockIncomingHit(move, target, 0, ref block);
// Assert
await Assert.That(GetStatusSet(attacker)).IsEqualTo("poisoned");
}
/// <summary>
/// Bulbapedia: "If Baneful Bunker blocks an attack that would make contact with the user, the attacker
/// becomes poisoned (unless they are immune)."
/// A blocked attack that does not make contact must not poison the attacker.
/// </summary>
[Test]
public async Task BlockIncomingHit_BlockedNonContactMove_DoesNotPoisonAttacker()
{
// Arrange
var (effect, move, target, attacker) = CreateBlockSetup(false, true);
var block = false;
// Act
effect.BlockIncomingHit(move, target, 0, ref block);
// Assert
await Assert.That(attacker.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse();
}
/// <summary>
/// Bulbapedia: "If Baneful Bunker blocks an attack that would make contact with the user, the attacker
/// becomes poisoned (unless they are immune)."
/// A contact move that bypasses the protection (it lacks the protect flag, e.g. Shadow Force) is not
/// blocked, so the attacker must not be poisoned.
/// </summary>
[Test]
public async Task BlockIncomingHit_ContactMoveThatBypassesProtection_DoesNotPoisonAttacker()
{
// Arrange
var (effect, move, target, attacker) = CreateBlockSetup(true, false);
var block = false;
// Act
effect.BlockIncomingHit(move, target, 0, ref block);
// Assert - the hit was not blocked, so no poison is applied
await Assert.That(block).IsFalse();
await Assert.That(attacker.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse();
}
}

View File

@@ -0,0 +1,255 @@
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.Plugin.Gen7.Scripts.Utils;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="BatonPass"/> move script.
/// Behavior is verified against the Bulbapedia page for Baton Pass (Generation VII).
/// In this codebase, volatile scripts that are transferred by Baton Pass are marked with
/// <see cref="IBatonPassException"/> (e.g. <see cref="AutotomizeEffect"/>).
/// </summary>
public class BatonPassTests
{
/// <summary>
/// Creates a mocked Pokémon with a real volatile <see cref="ScriptSet"/> and a real
/// <see cref="StatBoostStatisticSet"/>.
/// </summary>
private static IPokemon CreateMockPokemon(out IScriptSet volatileSet)
{
var pokemon = Substitute.For<IPokemon>();
pokemon.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
var set = new ScriptSet(pokemon);
pokemon.Volatile.Returns(set);
pokemon.StatBoost.Returns(new StatBoostStatisticSet());
volatileSet = set;
return pokemon;
}
/// <summary>
/// Creates a fully mocked test setup for Baton Pass tests. The Pokémon to switch in is stored in the
/// move choice's <see cref="IMoveChoice.AdditionalData"/> under the <c>to_switch</c> key.
/// </summary>
private static (BatonPass script, IExecutingMove move, IPokemon user, IPokemon toSwitch, IBattleSide side,
IScriptSet userVolatile, IScriptSet switchInVolatile) CreateTestSetup()
{
var script = new BatonPass();
var move = Substitute.For<IExecutingMove>();
var user = CreateMockPokemon(out var userVolatile);
var toSwitch = CreateMockPokemon(out var switchInVolatile);
var moveChoice = Substitute.For<IMoveChoice>();
moveChoice.AdditionalData.Returns(new Dictionary<StringKey, object?> { { "to_switch", toSwitch } });
move.MoveChoice.Returns(moveChoice);
var side = Substitute.For<IBattleSide>();
var battle = Substitute.For<IBattle>();
battle.Sides.Returns(new[] { side });
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
battleData.SideIndex.Returns((byte)0);
battleData.Position.Returns((byte)1);
user.BattleData.Returns(battleData);
move.User.Returns(user);
return (script, move, user, toSwitch, side, userVolatile, switchInVolatile);
}
/// <summary>
/// Bulbapedia: "Baton Pass switches out the user and passes several effects to the Pokémon switched in
/// its place."
/// The user is swapped out for the chosen Pokémon on its own field position.
/// </summary>
[Test]
public void OnSecondaryEffect_SwitchTargetChosen_SwitchesOutUser()
{
// Arrange
var (script, move, user, toSwitch, side, _, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
side.Received(1).SwapPokemon((byte)1, toSwitch);
}
/// <summary>
/// Bulbapedia: "Baton Pass transfers all temporary stat stage increases and decreases".
/// Each stat stage (including evasion and accuracy) is copied to the Pokémon that is switched in.
/// </summary>
[Test, Arguments(Statistic.Attack, (sbyte)6), Arguments(Statistic.Defense, (sbyte)-6),
Arguments(Statistic.SpecialAttack, (sbyte)2), Arguments(Statistic.SpecialDefense, (sbyte)-1),
Arguments(Statistic.Speed, (sbyte)3), Arguments(Statistic.Evasion, (sbyte)1),
Arguments(Statistic.Accuracy, (sbyte)-2)]
public async Task OnSecondaryEffect_StatBoost_TransferredToSwitchIn(Statistic stat, sbyte boost)
{
// Arrange
var (script, move, user, toSwitch, _, _, _) = CreateTestSetup();
user.StatBoost.SetStatistic(stat, boost);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(toSwitch.StatBoost.GetStatistic(stat)).IsEqualTo(boost);
}
/// <summary>
/// Bulbapedia: "Baton Pass transfers all temporary stat stage increases and decreases".
/// After the stat stages are copied, the switched-in Pokémon's boosted stats are recalculated.
/// </summary>
[Test]
public void OnSecondaryEffect_StatBoostsTransferred_RecalculatesSwitchInStats()
{
// Arrange
var (script, move, user, toSwitch, _, _, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
toSwitch.Received(1).RecalculateBoostedStats();
}
/// <summary>
/// Bulbapedia: "Baton Pass switches out the user and passes several effects to the Pokémon switched in
/// its place."
/// A volatile script that is marked as transferable (<see cref="IBatonPassException"/>, such as
/// <see cref="AutotomizeEffect"/>) is passed to the switched-in Pokémon.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TransferableVolatile_PassedToSwitchIn()
{
// Arrange
var (script, move, user, _, _, userVolatile, switchInVolatile) = CreateTestSetup();
userVolatile.Add(new AutotomizeEffect());
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(switchInVolatile.Get<AutotomizeEffect>()).IsNotNull();
}
/// <summary>
/// Bulbapedia: "Baton Pass also passes some volatile status conditions, namely confusion, getting pumped,
/// escape prevention, seeding, Curse, Substitute, Ingrain, Aqua Ring" — effects outside that list are not
/// passed. The consecutive-protection counter (<see cref="ProtectionFailureScript"/>) is not transferable,
/// so it must not end up on the switched-in Pokémon.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NonTransferableVolatile_NotPassedToSwitchIn()
{
// Arrange
var (script, move, user, _, _, userVolatile, switchInVolatile) = CreateTestSetup();
userVolatile.Add(new ProtectionFailureScript());
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(switchInVolatile.Get<ProtectionFailureScript>()).IsNull();
}
/// <summary>
/// Bulbapedia: "Baton Pass switches out the user and passes several effects to the Pokémon switched in
/// its place."
/// A transferable volatile must be passed even when the user also has a non-transferable volatile that
/// was added before it.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TransferableVolatileAfterNonTransferable_StillPassedToSwitchIn()
{
// Arrange
var (script, move, user, _, _, userVolatile, switchInVolatile) = CreateTestSetup();
userVolatile.Add(new ProtectionFailureScript());
userVolatile.Add(new AutotomizeEffect());
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(switchInVolatile.Get<AutotomizeEffect>()).IsNotNull();
await Assert.That(switchInVolatile.Get<ProtectionFailureScript>()).IsNull();
}
/// <summary>
/// Bulbapedia: "Baton Pass switches out the user".
/// The user's volatile scripts leave the field with it: after the switch its volatile containers are empty.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserSwitchedOut_UserVolatilesCleared()
{
// Arrange
var (script, move, user, _, _, userVolatile, _) = CreateTestSetup();
userVolatile.Add(new AutotomizeEffect());
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(userVolatile.Get<AutotomizeEffect>()).IsNull();
}
/// <summary>
/// Technical test: without any <see cref="IMoveChoice.AdditionalData"/> there is no Pokémon to switch to,
/// so no switch happens.
/// </summary>
[Test]
public void OnSecondaryEffect_NoAdditionalData_DoesNotSwitch()
{
// Arrange
var (script, move, user, _, side, _, _) = CreateTestSetup();
move.MoveChoice.AdditionalData.Returns((Dictionary<StringKey, object?>?)null);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
side.DidNotReceive().SwapPokemon(Arg.Any<byte>(), Arg.Any<IPokemon?>());
}
/// <summary>
/// Technical test: if the stored switch target is not a Pokémon, no switch happens.
/// </summary>
[Test]
public void OnSecondaryEffect_SwitchTargetNotAPokemon_DoesNotSwitch()
{
// Arrange
var (script, move, user, _, side, _, _) = CreateTestSetup();
move.MoveChoice.AdditionalData.Returns(new Dictionary<StringKey, object?> { { "to_switch", null } });
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
side.DidNotReceive().SwapPokemon(Arg.Any<byte>(), Arg.Any<IPokemon?>());
}
/// <summary>
/// Technical test: if the user has no <see cref="IPokemon.BattleData"/>, the script returns without
/// switching and without clearing the user's volatile scripts.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NoBattleData_DoesNotSwitch()
{
// Arrange
var (script, move, user, _, side, userVolatile, _) = CreateTestSetup();
user.BattleData.Returns((IPokemonBattleData?)null);
userVolatile.Add(new AutotomizeEffect());
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
side.DidNotReceive().SwapPokemon(Arg.Any<byte>(), Arg.Any<IPokemon?>());
await Assert.That(userVolatile.Get<AutotomizeEffect>()).IsNotNull();
}
}

View File

@@ -0,0 +1,193 @@
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="BeakBlast"/> move script and its attached <see cref="BeakBlastEffect"/>.
/// Behavior is verified against the Bulbapedia page for Beak Blast (Generation VII).
/// </summary>
public class BeakBlastTests
{
/// <summary>
/// Creates a fully mocked setup for driving <see cref="BeakBlast.OnBeforeTurnStart"/>.
/// </summary>
private static (BeakBlast script, ITurnChoice choice, IPokemon user, IScriptSet volatileSet, EventHook eventHook)
CreateChargeSetup(bool hasBattleData = true)
{
var script = new BeakBlast();
var user = Substitute.For<IPokemon>();
user.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet volatileSet = new ScriptSet(user);
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 (script, choice, user, volatileSet, eventHook);
}
/// <summary>
/// Bulbapedia: "The user charges up at the beginning of the turn and executes Beak Blast at -3 priority."
/// Before the turn starts, the charging <see cref="BeakBlastEffect"/> is attached to the user.
/// </summary>
[Test]
public async Task OnBeforeTurnStart_UserInBattle_AddsChargeEffect()
{
// Arrange
var (script, choice, _, volatileSet, _) = CreateChargeSetup();
// Act
script.OnBeforeTurnStart(choice);
// Assert
await Assert.That(volatileSet.Get<BeakBlastEffect>()).IsNotNull();
}
/// <summary>
/// Bulbapedia: "Its charging message is displayed before any other moves."
/// The charge fires a dialog event when the turn starts.
/// </summary>
[Test]
public async Task OnBeforeTurnStart_UserInBattle_FiresChargingMessage()
{
// Arrange
var (script, choice, user, _, eventHook) = CreateChargeSetup();
DialogEvent? capturedEvent = null;
eventHook.Handler += (_, args) =>
{
if (args is DialogEvent dialogEvent)
capturedEvent = dialogEvent;
};
// Act
script.OnBeforeTurnStart(choice);
// Assert
await Assert.That(capturedEvent).IsNotNull();
await Assert.That(capturedEvent!.Message).IsEqualTo("beak_blast_charge");
await Assert.That(capturedEvent.Parameters!["user"]).IsEqualTo(user);
}
/// <summary>
/// Technical test: a Pokémon without <see cref="IPokemon.BattleData"/> is not in battle, so no charging
/// phase starts.
/// </summary>
[Test]
public async Task OnBeforeTurnStart_NoBattleData_DoesNotAddChargeEffect()
{
// Arrange
var (script, choice, _, volatileSet, _) = CreateChargeSetup(false);
// Act
script.OnBeforeTurnStart(choice);
// Assert
await Assert.That(volatileSet.Get<BeakBlastEffect>()).IsNull();
}
/// <summary>
/// Bulbapedia: "All who make contact with the user during the charging phase are burned."
/// The charging phase ends when the move executes, so the <see cref="BeakBlastEffect"/> is removed again.
/// </summary>
[Test]
public async Task OnSecondaryEffect_MoveExecuted_RemovesChargeEffect()
{
// Arrange
var script = new BeakBlast();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var user = Substitute.For<IPokemon>();
user.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet volatileSet = new ScriptSet(user);
user.Volatile.Returns(volatileSet);
move.User.Returns(user);
volatileSet.Add(new BeakBlastEffect());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Get<BeakBlastEffect>()).IsNull();
}
/// <summary>
/// Creates a fully mocked setup for driving <see cref="BeakBlastEffect.OnIncomingHit"/>.
/// </summary>
private static (BeakBlastEffect effect, IExecutingMove move, IPokemon target, IPokemon attacker)
CreateIncomingHitSetup(bool isContact)
{
var effect = new BeakBlastEffect();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
hitData.IsContact.Returns(isContact);
move.GetHitData(target, 0).Returns(hitData);
var attacker = Substitute.For<IPokemon>();
move.User.Returns(attacker);
return (effect, move, target, attacker);
}
/// <summary>
/// Helper to extract the status name from a Pokémon's received <see cref="IPokemon.SetStatus"/> calls.
/// </summary>
private static string? GetStatusSet(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "SetStatus");
return call != null ? ((StringKey)call.GetArguments()[0]!).ToString() : null;
}
/// <summary>
/// Bulbapedia: "All who make contact with the user during the charging phase are burned."
/// </summary>
[Test]
public async Task OnIncomingHit_ContactMove_BurnsAttacker()
{
// Arrange
var (effect, move, target, attacker) = CreateIncomingHitSetup(true);
// Act
effect.OnIncomingHit(move, target, 0);
// Assert
await Assert.That(GetStatusSet(attacker)).IsEqualTo("burned");
}
/// <summary>
/// Bulbapedia: "All who make contact with the user during the charging phase are burned."
/// A hit that does not make contact must not burn the attacker.
/// </summary>
[Test]
public async Task OnIncomingHit_NonContactMove_DoesNotBurnAttacker()
{
// Arrange
var (effect, move, target, attacker) = CreateIncomingHitSetup(false);
// Act
effect.OnIncomingHit(move, target, 0);
// Assert
await Assert.That(attacker.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse();
}
}

View File

@@ -0,0 +1,229 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Status;
using PkmnLib.Static;
using PkmnLib.Static.Species;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="BeatUp"/> move script.
/// Gen VII Bulbapedia behavior: "Beat Up inflicts damage on the target from the user and from each conscious
/// Pokémon in the user's party that does not have a non-volatile status." Since Generation V, "the base power
/// per strike is no longer 10, but instead individually based on the Attack base stats of the party Pokémon:
/// BasePower = BaseAttack(PartyMember)/10 + 5".
/// </summary>
public class BeatUpTests
{
/// <summary>
/// Creates a mocked party member with the given base Attack stat, usability, and optional non-volatile
/// status script in its <see cref="IPokemon.StatusScript"/>.
/// </summary>
private static IPokemon CreatePartyMember(ushort baseAttack = 100, bool usable = true, Script? status = null)
{
var pokemon = Substitute.For<IPokemon>();
pokemon.IsUsable.Returns(usable);
pokemon.StatusScript.Returns(status == null ? new ScriptContainer() : new ScriptContainer(status));
var form = Substitute.For<IForm>();
form.BaseStats.Returns(new ImmutableStatisticSet<ushort>(100, baseAttack, 100, 100, 100, 100));
pokemon.Form.Returns(form);
return pokemon;
}
/// <summary>
/// Creates a fully mocked test setup where the user and the given other Pokémon form a party in a battle.
/// </summary>
private static (BeatUp script, IMoveChoice choice, IExecutingMove move) CreateTestSetup(IPokemon user,
params IPokemon?[] otherPartyMembers)
{
var script = new BeatUp();
var members = new List<IPokemon?> { user };
members.AddRange(otherPartyMembers);
var party = Substitute.For<IPokemonParty>();
party.GetEnumerator().Returns(_ => members.GetEnumerator());
var battleParty = Substitute.For<IBattleParty>();
battleParty.Party.Returns(party);
var battle = Substitute.For<IBattle>();
battle.Parties.Returns(new[] { battleParty });
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
user.BattleData.Returns(battleData);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
return (script, choice, move);
}
/// <summary>
/// Bulbapedia: "Beat Up inflicts damage on the target from the user and from each conscious Pokémon in the
/// user's party that does not have a non-volatile status."
/// With the user and two healthy allies, the move strikes three times.
/// </summary>
[Test]
public async Task ChangeNumberOfHits_AllPartyMembersEligible_OneHitPerMember()
{
// Arrange
var user = CreatePartyMember();
var (script, choice, _) = CreateTestSetup(user, CreatePartyMember(), CreatePartyMember());
byte numberOfHits = 1;
// Act
script.ChangeNumberOfHits(choice, ref numberOfHits);
// Assert
await Assert.That(numberOfHits).IsEqualTo((byte)3);
}
/// <summary>
/// Bulbapedia: "Beat Up inflicts damage on the target from the user and from each conscious Pokémon in the
/// user's party".
/// A fainted (not conscious) party member does not contribute a strike.
/// </summary>
[Test]
public async Task ChangeNumberOfHits_FaintedPartyMember_Excluded()
{
// Arrange
var user = CreatePartyMember();
var (script, choice, _) = CreateTestSetup(user, CreatePartyMember(usable: false), CreatePartyMember());
byte numberOfHits = 1;
// Act
script.ChangeNumberOfHits(choice, ref numberOfHits);
// Assert
await Assert.That(numberOfHits).IsEqualTo((byte)2);
}
/// <summary>
/// Bulbapedia: "each conscious Pokémon in the user's party that does not have a non-volatile status".
/// A party member with a non-volatile status (here <see cref="Burned"/>) does not contribute a strike.
/// </summary>
[Test]
public async Task ChangeNumberOfHits_StatusedPartyMember_Excluded()
{
// Arrange
var user = CreatePartyMember();
var (script, choice, _) = CreateTestSetup(user, CreatePartyMember(status: new Burned()));
byte numberOfHits = 1;
// Act
script.ChangeNumberOfHits(choice, ref numberOfHits);
// Assert
await Assert.That(numberOfHits).IsEqualTo((byte)1);
}
/// <summary>
/// Technical test: outside of battle (no <see cref="IPokemon.BattleData"/>) there are no relevant party
/// members, and the number of hits falls back to a single strike.
/// </summary>
[Test]
public async Task ChangeNumberOfHits_NoBattleData_SingleHit()
{
// Arrange
var script = new BeatUp();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
byte numberOfHits = 3;
// Act
script.ChangeNumberOfHits(choice, ref numberOfHits);
// Assert
await Assert.That(numberOfHits).IsEqualTo((byte)1);
}
/// <summary>
/// Bulbapedia: "the base power per strike is no longer 10, but instead individually based on the Attack
/// base stats of the party Pokémon: BasePower = BaseAttack(PartyMember)/10 + 5".
/// Verifies the formula, including integer truncation of the division.
/// </summary>
[Test, Arguments((ushort)10, (ushort)6), Arguments((ushort)55, (ushort)10), Arguments((ushort)59, (ushort)10),
Arguments((ushort)100, (ushort)15), Arguments((ushort)255, (ushort)30)]
public async Task ChangeBasePower_UsesBaseAttackFormula(ushort baseAttack, ushort expectedBasePower)
{
// Arrange
var user = CreatePartyMember(baseAttack);
var (script, _, move) = CreateTestSetup(user);
var target = Substitute.For<IPokemon>();
ushort basePower = 10;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expectedBasePower);
}
/// <summary>
/// Bulbapedia: "the base power per strike is [...] individually based on the Attack base stats of the party
/// Pokémon". The second strike uses the second eligible party member's base Attack, not the user's.
/// </summary>
[Test]
public async Task ChangeBasePower_SecondHit_UsesSecondPartyMembersBaseAttack()
{
// Arrange
var user = CreatePartyMember(100);
var (script, _, move) = CreateTestSetup(user, CreatePartyMember(250));
var target = Substitute.For<IPokemon>();
ushort basePower = 10;
// Act
script.ChangeBasePower(move, target, 1, ref basePower);
// Assert - 250 / 10 + 5 = 30
await Assert.That(basePower).IsEqualTo((ushort)30);
}
/// <summary>
/// Bulbapedia: strikes only come from party members "that [do] not have a non-volatile status".
/// An ineligible member is skipped entirely, so the strike after the user's uses the next eligible
/// member's base Attack.
/// </summary>
[Test]
public async Task ChangeBasePower_StatusedMemberSkipped_UsesNextEligibleMember()
{
// Arrange
var user = CreatePartyMember(100);
var (script, _, move) = CreateTestSetup(user, CreatePartyMember(250, status: new Burned()),
CreatePartyMember(60));
var target = Substitute.For<IPokemon>();
ushort basePower = 10;
// Act
script.ChangeBasePower(move, target, 1, ref basePower);
// Assert - the burned member is skipped: 60 / 10 + 5 = 11
await Assert.That(basePower).IsEqualTo((ushort)11);
}
/// <summary>
/// Technical test: a hit index beyond the number of eligible party members leaves the base power unchanged
/// instead of throwing.
/// </summary>
[Test]
public async Task ChangeBasePower_HitIndexBeyondEligibleMembers_BasePowerUnchanged()
{
// Arrange
var user = CreatePartyMember(100);
var (script, _, move) = CreateTestSetup(user);
var target = Substitute.For<IPokemon>();
ushort basePower = 10;
// Act
script.ChangeBasePower(move, target, 3, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)10);
}
}

View File

@@ -0,0 +1,133 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Belch"/> move script.
/// Gen VII Bulbapedia behavior: "Belch deals damage. It cannot be selected unless the user has previously eaten
/// a Berry in the current battle."
/// </summary>
public class BelchTests
{
/// <summary>
/// Creates a fully mocked test setup where the user's <see cref="IPokemonBattleData.ConsumedItems"/>
/// contains one item per given <see cref="ItemCategory"/>.
/// </summary>
private static (Belch script, IMoveChoice choice) CreateTestSetup(params ItemCategory[] consumedItemCategories)
{
var script = new Belch();
var user = Substitute.For<IPokemon>();
var items = consumedItemCategories.Select(category =>
{
var item = Substitute.For<IItem>();
item.Category.Returns(category);
return item;
}).ToArray();
var battleData = Substitute.For<IPokemonBattleData>();
battleData.ConsumedItems.Returns(items);
user.BattleData.Returns(battleData);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
return (script, choice);
}
/// <summary>
/// Bulbapedia: "It cannot be selected unless the user has previously eaten a Berry in the current battle."
/// A user that has eaten a Berry may select Belch.
/// </summary>
[Test]
public async Task PreventMoveSelection_UserAteBerry_SelectionAllowed()
{
// Arrange
var (script, choice) = CreateTestSetup(ItemCategory.Berry);
var prevent = false;
// Act
script.PreventMoveSelection(choice, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia: "It cannot be selected unless the user has previously eaten a Berry in the current battle."
/// A user that has not consumed anything cannot select Belch.
/// </summary>
[Test]
public async Task PreventMoveSelection_NoItemsConsumed_SelectionPrevented()
{
// Arrange
var (script, choice) = CreateTestSetup();
var prevent = false;
// Act
script.PreventMoveSelection(choice, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "It cannot be selected unless the user has previously eaten a Berry in the current battle."
/// Having consumed only non-Berry items does not satisfy the requirement.
/// </summary>
[Test, Arguments(ItemCategory.MiscItem), Arguments(ItemCategory.Medicine)]
public async Task PreventMoveSelection_OnlyNonBerryItemConsumed_SelectionPrevented(ItemCategory category)
{
// Arrange
var (script, choice) = CreateTestSetup(category);
var prevent = false;
// Act
script.PreventMoveSelection(choice, ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "It cannot be selected unless the user has previously eaten a Berry in the current battle."
/// The Berry does not have to be the only consumed item: a Berry among other consumed items is enough.
/// </summary>
[Test]
public async Task PreventMoveSelection_BerryAmongOtherConsumedItems_SelectionAllowed()
{
// Arrange
var (script, choice) = CreateTestSetup(ItemCategory.MiscItem, ItemCategory.Berry);
var prevent = false;
// Act
script.PreventMoveSelection(choice, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Technical test: outside of battle (no <see cref="IPokemon.BattleData"/>) the script does not prevent
/// selection.
/// </summary>
[Test]
public async Task PreventMoveSelection_NoBattleData_SelectionAllowed()
{
// Arrange
var script = new Belch();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
var prevent = false;
// Act
script.PreventMoveSelection(choice, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
}

View File

@@ -0,0 +1,203 @@
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="BellyDrum"/> move script.
/// Gen VII Bulbapedia behavior: "Belly Drum deducts half of the user's maximum HP (rounded down) from its
/// current HP and, in return, it maximizes the user's Attack stat by raising it to +6 stages".
/// </summary>
public class BellyDrumTests
{
/// <summary>
/// Creates a fully mocked test setup for Belly Drum. The target of the secondary effect is the user itself.
/// </summary>
private static (BellyDrum script, IExecutingMove move, IPokemon user, IHitData hitData) CreateTestSetup(uint maxHp,
uint currentHp, sbyte attackBoost = 0)
{
var script = new BellyDrum();
var user = Substitute.For<IPokemon>();
user.BoostedStats.Returns(new StatisticSet<uint>(maxHp, 10, 10, 10, 10, 10));
user.CurrentHealth.Returns(currentHp);
user.StatBoost.Returns(new StatBoostStatisticSet(0, attackBoost, 0, 0, 0, 0));
var hitData = Substitute.For<IHitData>();
var move = Substitute.For<IExecutingMove>();
move.GetHitData(user, 0).Returns(hitData);
move.User.Returns(user);
return (script, move, user, hitData);
}
/// <summary>
/// Helper to extract the received Damage call from the user, if any.
/// </summary>
private static (uint damage, DamageSource source, bool forceDamage)? GetDamageCall(IPokemon user)
{
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
if (call == null)
return null;
var args = call.GetArguments();
return ((uint)args[0]!, (DamageSource)args[1]!, (bool)args[3]!);
}
/// <summary>
/// Helper to extract the received ChangeStatBoost call from the user, if any.
/// </summary>
private static (Statistic stat, sbyte change, bool selfInflicted)? GetStatBoostCall(IPokemon user)
{
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
if (call == null)
return null;
var args = call.GetArguments();
return ((Statistic)args[0]!, (sbyte)args[1]!, (bool)args[2]!);
}
/// <summary>
/// Bulbapedia: "Belly Drum deducts half of the user's maximum HP (rounded down) from its current HP".
/// Verifies the deduction amount, including the rounding down for odd maximum HP values. The deduction is
/// a cost rather than regular damage, so it is forced <see cref="DamageSource.Misc"/> damage.
/// </summary>
[Test, Arguments(100u, 50u), Arguments(101u, 50u), Arguments(99u, 49u), Arguments(255u, 127u)]
public async Task OnSecondaryEffect_SufficientHp_DeductsHalfMaxHpRoundedDown(uint maxHp, uint expectedDeduction)
{
// Arrange
var (script, move, user, _) = CreateTestSetup(maxHp, maxHp);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(GetDamageCall(user)).IsEqualTo((expectedDeduction, DamageSource.Misc, true));
}
/// <summary>
/// Bulbapedia: "it maximizes the user's Attack stat by raising it to +6 stages".
/// The user receives a self-inflicted Attack boost large enough to reach the +6 cap.
/// </summary>
[Test]
public async Task OnSecondaryEffect_SufficientHp_MaximizesAttack()
{
// Arrange
var (script, move, user, _) = CreateTestSetup(100, 100);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
var boostCall = GetStatBoostCall(user);
await Assert.That(boostCall).IsNotNull();
await Assert.That(boostCall!.Value.stat).IsEqualTo(Statistic.Attack);
await Assert.That(boostCall.Value.change).IsGreaterThanOrEqualTo((sbyte)6);
await Assert.That(boostCall.Value.selfInflicted).IsTrue();
}
/// <summary>
/// Bulbapedia: "it maximizes the user's Attack stat by raising it to +6 stages, even if the user's
/// temporary Attack bonus stages were below 0 prior to using Belly Drum."
/// From -6 stages only a raise of at least 12 stages reaches +6.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AttackStagesBelowZero_StillRaisedToPlusSix()
{
// Arrange
var (script, move, user, _) = CreateTestSetup(100, 100, -6);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
var boostCall = GetStatBoostCall(user);
await Assert.That(boostCall).IsNotNull();
await Assert.That(boostCall!.Value.change).IsGreaterThanOrEqualTo((sbyte)12);
}
/// <summary>
/// Bulbapedia: "Belly Drum fails if the user's current HP is less than half its maximum".
/// </summary>
[Test]
public void OnSecondaryEffect_CurrentHpBelowHalf_Fails()
{
// Arrange
var (script, move, user, hitData) = CreateTestSetup(100, 49);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
hitData.Received(1).Fail();
}
/// <summary>
/// Bulbapedia: "Belly Drum fails if the user's current HP is less than half its maximum".
/// When the move fails, no HP is deducted and the Attack stat is not changed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_CurrentHpBelowHalf_NoHpDeductedAndNoAttackChange()
{
// Arrange
var (script, move, user, _) = CreateTestSetup(100, 49);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(GetDamageCall(user)).IsNull();
await Assert.That(GetStatBoostCall(user)).IsNull();
}
/// <summary>
/// Bulbapedia: "Belly Drum fails if the user's current HP is less than half its maximum".
/// At exactly half its maximum HP the user cannot pay the cost without fainting (deducting half of the
/// maximum HP would reduce it to 0 HP), so the move fails as well.
/// </summary>
[Test]
public void OnSecondaryEffect_CurrentHpExactlyHalf_Fails()
{
// Arrange
var (script, move, user, hitData) = CreateTestSetup(100, 50);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
hitData.Received(1).Fail();
}
/// <summary>
/// Bulbapedia: "Belly Drum fails if the user's current HP is less than half its maximum, or if the user's
/// Attack is already at +6 (even if the user has Contrary)."
/// </summary>
[Test]
public void OnSecondaryEffect_AttackAlreadyAtPlusSix_Fails()
{
// Arrange
var (script, move, user, hitData) = CreateTestSetup(100, 100, 6);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
hitData.Received(1).Fail();
}
/// <summary>
/// Bulbapedia: "Belly Drum fails if [...] the user's Attack is already at +6".
/// A failed Belly Drum does not deduct HP: the HP cost only applies when the move works (the only
/// exception Bulbapedia lists is a Contrary user already at -6 stages).
/// </summary>
[Test]
public async Task OnSecondaryEffect_AttackAlreadyAtPlusSix_NoHpDeducted()
{
// Arrange
var (script, move, user, _) = CreateTestSetup(100, 100, 6);
// Act
script.OnSecondaryEffect(move, user, 0);
// Assert
await Assert.That(GetDamageCall(user)).IsNull();
}
}

View File

@@ -0,0 +1,175 @@
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="Bestow"/> move script.
/// Gen VII Bulbapedia behavior: "The user transfers its held item to the target. It fails if the user has no
/// held item, the target already has a held item, or the target is behind a substitute. [...] Items given away
/// in Trainer battles return to the original Pokémon after the battle."
/// </summary>
public class BestowTests
{
private static (Bestow script, IExecutingMove move, IPokemon user, IPokemon target, IHitData hitData)
CreateTestSetup(IItem? userItem, IItem? targetItem)
{
var script = new Bestow();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
target.HeldItem.Returns(targetItem);
var user = Substitute.For<IPokemon>();
user.HeldItem.Returns(userItem);
user.RemoveHeldItemForBattle().Returns(userItem);
move.User.Returns(user);
return (script, move, user, target, hitData);
}
/// <summary>
/// Bulbapedia: "The user transfers its held item to the target."
/// The target ends up holding the user's item.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetHasNoItem_TargetReceivesUsersItem()
{
// Arrange
var userItem = Substitute.For<IItem>();
var (script, move, _, target, _) = CreateTestSetup(userItem, null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.Received(1).ForceSetHeldItem(userItem);
}
/// <summary>
/// Bulbapedia: "The user transfers its held item to the target."
/// A successful transfer does not fail the move.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetHasNoItem_MoveDoesNotFail()
{
// Arrange
var userItem = Substitute.For<IItem>();
var (script, move, _, target, hitData) = CreateTestSetup(userItem, null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: "Items given away in Trainer battles return to the original Pokémon after the battle."
/// The item is taken from the user through <see cref="IPokemon.RemoveHeldItemForBattle"/>, which only
/// removes the item for the duration of the battle.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetHasNoItem_UserItemIsRemovedForBattleOnly()
{
// Arrange
var userItem = Substitute.For<IItem>();
var (script, move, user, target, _) = CreateTestSetup(userItem, null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
user.Received(1).RemoveHeldItemForBattle();
}
/// <summary>
/// Bulbapedia: "It fails if the user has no held item".
/// </summary>
[Test]
public void OnSecondaryEffect_UserHasNoHeldItem_MoveFails()
{
// Arrange
var (script, move, _, target, hitData) = CreateTestSetup(null, null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
}
/// <summary>
/// Bulbapedia: "It fails if the user has no held item".
/// When the move fails, no item is given to the target.
/// </summary>
[Test]
public void OnSecondaryEffect_UserHasNoHeldItem_TargetDoesNotReceiveAnItem()
{
// Arrange
var (script, move, _, target, _) = CreateTestSetup(null, null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.DidNotReceive().ForceSetHeldItem(Arg.Any<IItem>());
}
/// <summary>
/// Bulbapedia: "It fails if [...] the target already has a held item".
/// </summary>
[Test]
public void OnSecondaryEffect_TargetAlreadyHasHeldItem_MoveFails()
{
// Arrange
var (script, move, _, target, hitData) = CreateTestSetup(Substitute.For<IItem>(), Substitute.For<IItem>());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
}
/// <summary>
/// Bulbapedia: "It fails if [...] the target already has a held item".
/// The target's own held item is left untouched by the failed move.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetAlreadyHasHeldItem_TargetItemIsNotReplaced()
{
// Arrange
var (script, move, _, target, _) = CreateTestSetup(Substitute.For<IItem>(), Substitute.For<IItem>());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.DidNotReceive().ForceSetHeldItem(Arg.Any<IItem>());
}
/// <summary>
/// Bulbapedia: "It fails if [...] the target already has a held item".
/// A failed Bestow must not cost the user its held item: the item is either never taken from the user, or
/// it is given back after the failure check.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetAlreadyHasHeldItem_UserKeepsItsHeldItem()
{
// Arrange
var userItem = Substitute.For<IItem>();
var (script, move, user, target, _) = CreateTestSetup(userItem, Substitute.For<IItem>());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - the user still holds its item: it was either never removed, or restored after the failure.
var removed = user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "RemoveHeldItemForBattle");
var restored = user.ReceivedCalls().Any(c =>
c.GetMethodInfo().Name == "ForceSetHeldItem" && ReferenceEquals(c.GetArguments()[0], userItem));
await Assert.That(!removed || restored).IsTrue();
}
}

View File

@@ -0,0 +1,280 @@
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="Bide"/> move script and its <see cref="BideEffect"/> volatile.
/// Gen VII Bulbapedia behavior (base plus Gen III-VII deltas): the user idles while storing the damage it
/// receives, then "Bide will do damage equal to twice the damage received during the idling period", enduring
/// "attacks for two turns" and hitting "the last Pokémon to attack the user, even if this is an ally".
/// </summary>
public class BideTests
{
private static (Bide script, IExecutingMove move, IPokemon user, IPokemon target, IScriptSet userVolatile, IHitData
hitData) CreateTestSetup()
{
var script = new Bide();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
var user = Substitute.For<IPokemon>();
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
// A real script set so the volatile Bide effect can actually be added, retrieved and removed.
var userVolatile = new ScriptSet(user);
user.Volatile.Returns(userVolatile);
move.User.Returns(user);
return (script, move, user, target, userVolatile, hitData);
}
private static IPokemon CreateAttacker(bool onBattlefield = true)
{
var attacker = Substitute.For<IPokemon>();
var battleData = Substitute.For<IPokemonBattleData>();
battleData.IsOnBattlefield.Returns(onBattlefield);
attacker.BattleData.Returns(battleData);
return attacker;
}
/// <summary>
/// Adds a <see cref="BideEffect"/> to the user's volatile scripts, as if Bide has already been storing
/// energy for the given number of executed turns.
/// </summary>
private static BideEffect AddStoredBideEffect(IScriptSet userVolatile, IPokemon user, byte turns, uint damageTaken,
params IPokemon[] hitBy)
{
var effect = new BideEffect(user)
{
Turns = turns,
DamageTaken = damageTaken,
};
effect.HitBy.AddRange(hitBy);
userVolatile.Add(effect);
return effect;
}
/// <summary>
/// Helper to check whether a Pokémon received any Damage call.
/// </summary>
private static bool ReceivedDamage(IPokemon pokemon) =>
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage");
/// <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>
/// 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>
/// Bulbapedia: "After Bide is selected, the user will be unable to select a move for an idling period".
/// The first use adds the <see cref="BideEffect"/> volatile to the user, which forces the Bide choice on
/// the following turns and records incoming damage.
/// </summary>
[Test]
public async Task OnSecondaryEffect_FirstUse_AddsBideEffectToUserVolatile()
{
// Arrange
var (script, move, _, target, userVolatile, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(userVolatile.Get<BideEffect>()).IsNotNull();
}
/// <summary>
/// Bulbapedia: "Bide will now only endure attacks for two turns."
/// The turn Bide is selected begins the storing period, which spans two more turns; the stored damage is
/// only released on the third execution, so on the second execution nothing is released yet.
/// </summary>
[Test]
public async Task OnSecondaryEffect_SecondExecution_DoesNotReleaseStoredDamage()
{
// Arrange
var (script, move, user, target, userVolatile, _) = CreateTestSetup();
var attacker = CreateAttacker();
_ = AddStoredBideEffect(userVolatile, user, 1, 50, attacker);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - still storing, so the attacker has not been damaged
await Assert.That(ReceivedDamage(attacker)).IsFalse();
}
/// <summary>
/// Bulbapedia: "Bide will do damage equal to twice the damage received during the idling period."
/// </summary>
[Test, Arguments(50u, 100u), Arguments(1u, 2u), Arguments(123u, 246u)]
public async Task OnSecondaryEffect_Release_DealsTwiceTheStoredDamage(uint damageTaken, uint expectedDamage)
{
// Arrange
var (script, move, user, target, userVolatile, _) = CreateTestSetup();
var attacker = CreateAttacker();
_ = AddStoredBideEffect(userVolatile, user, 2, damageTaken, attacker);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageAmount(attacker)).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: "Bide will do damage equal to twice the damage received during the idling period."
/// The released damage is dealt as move damage, not as indirect damage.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Release_DealsMoveDamage()
{
// Arrange
var (script, move, user, target, userVolatile, _) = CreateTestSetup();
var attacker = CreateAttacker();
_ = AddStoredBideEffect(userVolatile, user, 2, 50, attacker);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageSource(attacker)).IsEqualTo(DamageSource.MoveDamage);
}
/// <summary>
/// Bulbapedia: "Bide hits the last Pokémon to attack the user, even if this is an ally."
/// </summary>
[Test]
public async Task OnSecondaryEffect_Release_HitsLastPokemonThatAttackedUser()
{
// Arrange
var (script, move, user, target, userVolatile, _) = CreateTestSetup();
var firstAttacker = CreateAttacker();
var lastAttacker = CreateAttacker();
_ = AddStoredBideEffect(userVolatile, user, 2, 50, firstAttacker, lastAttacker);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - only the last attacker is hit
await Assert.That(ReceivedDamage(lastAttacker)).IsTrue();
await Assert.That(ReceivedDamage(firstAttacker)).IsFalse();
}
/// <summary>
/// Bulbapedia: "this attack will hit the last Pokémon to attack the user".
/// A Pokémon that has since left the battlefield cannot be hit by the released damage.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Release_AttackerNoLongerOnBattlefield_IsNotDamaged()
{
// Arrange
var (script, move, user, target, userVolatile, _) = CreateTestSetup();
var departedAttacker = CreateAttacker(false);
_ = AddStoredBideEffect(userVolatile, user, 2, 50, departedAttacker);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedDamage(departedAttacker)).IsFalse();
}
/// <summary>
/// Bulbapedia: "If the user is not directly attacked during the biding period, it will fail on the turn it
/// would have released."
/// </summary>
[Test]
public void OnSecondaryEffect_ReleaseWithoutBeingAttacked_MoveFails()
{
// Arrange
var (script, move, user, target, userVolatile, hitData) = CreateTestSetup();
_ = AddStoredBideEffect(userVolatile, user, 2, 0);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
}
/// <summary>
/// Bulbapedia: "Once a Pokémon has started biding, it cannot choose to switch out until the attack is
/// complete."
/// Once the stored damage is released the attack is complete, so the <see cref="BideEffect"/> volatile must
/// be removed from the user; otherwise the user would remain locked into Bide indefinitely through
/// <see cref="BideEffect.ForceTurnSelection"/>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Release_RemovesBideEffectFromUser()
{
// Arrange
var (script, move, user, target, userVolatile, _) = CreateTestSetup();
var attacker = CreateAttacker();
_ = AddStoredBideEffect(userVolatile, user, 2, 50, attacker);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(userVolatile.Get<BideEffect>()).IsNull();
}
/// <summary>
/// Bulbapedia: "Bide will do damage equal to twice the damage received during the idling period."
/// The <see cref="BideEffect"/> volatile accumulates every chunk of damage the user takes while storing
/// energy.
/// </summary>
[Test]
public async Task BideEffect_OnDamage_AccumulatesDamageTaken()
{
// Arrange
var user = Substitute.For<IPokemon>();
var effect = new BideEffect(user);
// Act - the user drops from 100 to 60 HP, then from 60 to 50 HP
effect.OnDamage(user, DamageSource.MoveDamage, 100, 60);
effect.OnDamage(user, DamageSource.MoveDamage, 60, 50);
// Assert
await Assert.That(effect.DamageTaken).IsEqualTo(50u);
}
/// <summary>
/// Bulbapedia: "Bide hits the last Pokémon to attack the user, even if this is an ally."
/// The <see cref="BideEffect"/> volatile records every Pokémon that hits the user, in order, so the last
/// attacker can be targeted on release.
/// </summary>
[Test]
public async Task BideEffect_OnIncomingHit_RecordsAttacker()
{
// Arrange
var user = Substitute.For<IPokemon>();
var effect = new BideEffect(user);
var incomingMove = Substitute.For<IExecutingMove>();
var attacker = Substitute.For<IPokemon>();
incomingMove.User.Returns(attacker);
// Act
effect.OnIncomingHit(incomingMove, user, 0);
// Assert
await Assert.That(effect.HitBy.LastOrDefault()).IsEqualTo(attacker);
}
}

View File

@@ -0,0 +1,257 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Bind"/> move script and its <see cref="BindEffect"/> volatile.
/// Gen VII Bulbapedia behavior (Gen II base plus Gen V/VI deltas): "It also traps the target, preventing
/// switching and escape", "Its effect will last either 4 or 5 turns", and "The end turn damage of Bind is
/// increased from 1/16 to 1/8 of the target's maximum HP."
/// </summary>
public class BindTests
{
/// <summary>
/// Test helper script that modifies the Bind duration/damage through the <see cref="CustomTriggers.ModifyBind"/>
/// custom trigger, the same way the Grip Claw and Binding Band item scripts do.
/// </summary>
[Script(ScriptCategory.Pokemon, "test_modify_bind_trigger")]
private class ModifyBindTrigger : Script, IScriptCustomTrigger
{
private readonly int? _duration;
private readonly float? _damagePercent;
public ModifyBindTrigger(int? duration = null, float? damagePercent = null)
{
_duration = duration;
_damagePercent = damagePercent;
}
public void CustomTrigger(StringKey eventName, ICustomTriggerArgs args)
{
if (eventName != CustomTriggers.ModifyBind || args is not CustomTriggers.ModifyBindArgs bindArgs)
return;
if (_duration.HasValue)
bindArgs.Duration = _duration.Value;
if (_damagePercent.HasValue)
bindArgs.DamagePercent = _damagePercent.Value;
}
}
private static (Bind script, IExecutingMove move, IPokemon user, IPokemon target, IScriptSet targetVolatile)
CreateTestSetup(params Script[] userScripts)
{
var script = new Bind();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var targetVolatile = Substitute.For<IScriptSet>();
target.Volatile.Returns(targetVolatile);
var user = Substitute.For<IPokemon>();
// RunScriptHook iterates the user's scripts; give the mock a real iterator so the ModifyBind trigger
// pass runs (empty unless the test attaches scripts such as the Grip Claw / Binding Band stand-in).
var containers = userScripts.Select(IEnumerable<ScriptContainer> (s) => new ScriptContainer(s)).ToArray();
user.GetScripts().Returns(_ => new ScriptIterator(containers));
move.User.Returns(user);
return (script, move, user, target, targetVolatile);
}
/// <summary>
/// Helper to extract the <see cref="BindEffect"/> instance that was added to the target's volatile scripts.
/// </summary>
private static BindEffect? GetAddedEffect(IScriptSet targetVolatile) => targetVolatile.ReceivedCalls()
.Where(c => c.GetMethodInfo().Name == "Add").Select(c => c.GetArguments()[0]).OfType<BindEffect>()
.FirstOrDefault();
/// <summary>
/// Helper to extract the damage amount from the target's first received Damage call.
/// </summary>
private static uint? GetDamageAmount(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Runs end-of-turn handling on the effect repeatedly and counts how many turns dealt damage to the target.
/// </summary>
private static int CountEndTurnDamageTicks(BindEffect effect, IPokemon target, int maxTurns = 10)
{
var battle = Substitute.For<IBattle>();
for (var i = 0; i < maxTurns; i++)
effect.OnEndTurn(target, battle);
return target.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "Damage");
}
/// <summary>
/// Bulbapedia: "It also traps the target, preventing switching and escape."
/// Using Bind adds the <see cref="BindEffect"/> volatile to the target.
/// </summary>
[Test]
public void OnSecondaryEffect_AddsBindEffectToTargetVolatile()
{
// Arrange
var (script, move, _, target, targetVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
targetVolatile.Received(1).Add(Arg.Any<BindEffect>());
}
/// <summary>
/// Bulbapedia: "The end turn damage of Bind is increased from 1/16 to 1/8 of the target's maximum HP."
/// Tests several max HP values to ensure proper integer truncation of the 1/8 fraction.
/// </summary>
[Test, Arguments(160u, 20u), Arguments(100u, 12u), Arguments(15u, 1u)]
public async Task OnSecondaryEffect_Default_EndTurnDamageIsOneEighthOfTargetMaxHp(uint maxHealth,
uint expectedDamage)
{
// Arrange
var (script, move, _, target, targetVolatile) = CreateTestSetup();
target.MaxHealth.Returns(maxHealth);
// Act
script.OnSecondaryEffect(move, target, 0);
var effect = GetAddedEffect(targetVolatile);
await Assert.That(effect).IsNotNull();
effect!.OnEndTurn(target, Substitute.For<IBattle>());
// Assert
await Assert.That(GetDamageAmount(target)).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: "Its effect will last either 4 or 5 turns."
/// The added effect deals its end-of-turn damage for 4 or 5 turns and then ends.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Default_EffectLastsFourOrFiveTurns()
{
// Arrange
var (script, move, _, target, targetVolatile) = CreateTestSetup();
target.MaxHealth.Returns(160u);
// Act
script.OnSecondaryEffect(move, target, 0);
var effect = GetAddedEffect(targetVolatile);
await Assert.That(effect).IsNotNull();
var turnsWithDamage = CountEndTurnDamageTicks(effect!, target);
// Assert
await Assert.That(turnsWithDamage is 4 or 5).IsTrue();
}
/// <summary>
/// Bulbapedia: "If the user of Bind is holding a Grip Claw, the duration will be 7 turns."
/// The duration change flows through the <see cref="CustomTriggers.ModifyBind"/> custom trigger; a script
/// on the user that sets the duration to 7 results in an effect lasting 7 turns.
/// </summary>
[Test]
public async Task OnSecondaryEffect_DurationSetToSevenByTrigger_EffectLastsSevenTurns()
{
// Arrange
var (script, move, _, target, targetVolatile) = CreateTestSetup(new ModifyBindTrigger(7));
target.MaxHealth.Returns(160u);
// Act
script.OnSecondaryEffect(move, target, 0);
var effect = GetAddedEffect(targetVolatile);
await Assert.That(effect).IsNotNull();
var turnsWithDamage = CountEndTurnDamageTicks(effect!, target);
// Assert
await Assert.That(turnsWithDamage).IsEqualTo(7);
}
/// <summary>
/// Bulbapedia: "If the user is holding a Binding Band, the end turn damage of Bind will increase to 1/6 of
/// the target's maximum HP."
/// The damage change flows through the <see cref="CustomTriggers.ModifyBind"/> custom trigger; a script on
/// the user that sets the damage to 1/6 results in end-of-turn damage of 1/6 max HP.
/// </summary>
[Test, Arguments(60u, 10u), Arguments(48u, 8u)]
public async Task OnSecondaryEffect_DamageSetToOneSixthByTrigger_EndTurnDamageIsOneSixth(uint maxHealth,
uint expectedDamage)
{
// Arrange
var (script, move, _, target, targetVolatile) = CreateTestSetup(new ModifyBindTrigger(damagePercent: 1f / 6f));
target.MaxHealth.Returns(maxHealth);
// Act
script.OnSecondaryEffect(move, target, 0);
var effect = GetAddedEffect(targetVolatile);
await Assert.That(effect).IsNotNull();
effect!.OnEndTurn(target, Substitute.For<IBattle>());
// Assert
await Assert.That(GetDamageAmount(target)).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: "It also traps the target, preventing switching and escape."
/// While the effect is active, the target cannot switch out.
/// </summary>
[Test]
public async Task BindEffect_WhileActive_PreventsSwitching()
{
// Arrange
var target = Substitute.For<IPokemon>();
var effect = new BindEffect(target, 5, 1f / 8f);
var prevent = false;
// Act
effect.PreventSelfSwitch(Substitute.For<ISwitchChoice>(), ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "It also traps the target, preventing switching and escape."
/// While the effect is active, the target cannot flee.
/// </summary>
[Test]
public async Task BindEffect_WhileActive_PreventsRunningAway()
{
// Arrange
var target = Substitute.For<IPokemon>();
var effect = new BindEffect(target, 5, 1f / 8f);
var prevent = false;
// Act
effect.PreventSelfRunAway(Substitute.For<IFleeChoice>(), ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "Its effect will last either 4 or 5 turns."
/// Once the duration has run out, the target is no longer prevented from switching.
/// </summary>
[Test]
public async Task BindEffect_AfterDurationExpires_NoLongerPreventsSwitching()
{
// Arrange
var target = Substitute.For<IPokemon>();
target.MaxHealth.Returns(160u);
var effect = new BindEffect(target, 1, 1f / 8f);
// Act - the single remaining turn elapses
effect.OnEndTurn(target, Substitute.For<IBattle>());
var prevent = false;
effect.PreventSelfSwitch(Substitute.For<ISwitchChoice>(), ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
}

View File

@@ -0,0 +1,119 @@
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;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Block"/> move script.
/// Gen VII Bulbapedia behavior: "Block prevents the target from switching out or fleeing (including via
/// Teleport)." From Generation VI onward: "Block no longer affects Ghost-type Pokémon."
/// </summary>
public class BlockTests
{
private static (Block script, IExecutingMove move, IPokemon target, ScriptSet targetVolatile) CreateTestSetup()
{
var script = new Block();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
// Use a real script set so the volatile script added by Block can be inspected afterwards.
var targetVolatile = new ScriptSet(target);
target.Volatile.Returns(targetVolatile);
target.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
move.GetHitData(target, 0).Returns(Substitute.For<IHitData>());
return (script, move, target, targetVolatile);
}
/// <summary>
/// Bulbapedia: "Block prevents the target from switching out or fleeing (including via Teleport)."
/// Hitting the target attaches the <see cref="BlockEffect"/> volatile script to it.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Hit_AddsBlockEffectToTarget()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<BlockEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "Block prevents the target from switching out".
/// The <see cref="BlockEffect"/> applied by the move prevents the target's switch choices through
/// <see cref="BlockEffect.PreventSelfSwitch"/>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AppliedEffect_PreventsTargetFromSwitchingOut()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
script.OnSecondaryEffect(move, target, 0);
await Assert.That(targetVolatile.TryGet<BlockEffect>(out var effect)).IsTrue();
// Act
var prevent = false;
effect!.PreventSelfSwitch(Substitute.For<ISwitchChoice>(), ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "Block prevents the target from [...] fleeing (including via Teleport)."
/// The <see cref="BlockEffect"/> applied by the move prevents the target's flee choices through
/// <see cref="BlockEffect.PreventSelfRunAway"/>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AppliedEffect_PreventsTargetFromFleeing()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
script.OnSecondaryEffect(move, target, 0);
await Assert.That(targetVolatile.TryGet<BlockEffect>(out var effect)).IsTrue();
// Act
var prevent = false;
effect!.PreventSelfRunAway(Substitute.For<IFleeChoice>(), ref prevent);
// Assert
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia (Generation VI onward): "Block no longer affects Ghost-type Pokémon."
/// Using Block on a Ghost-type target should not trap it, so no <see cref="BlockEffect"/> may be added.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GhostTypeTarget_DoesNotTrapTarget()
{
// Arrange
var (script, move, target, targetVolatile) = CreateTestSetup();
var library = LibraryHelpers.LoadLibrary();
await Assert.That(library.StaticLibrary.Types.TryGetTypeIdentifier("ghost", out var ghostType)).IsTrue();
target.Types.Returns(new[] { ghostType });
var battle = Substitute.For<IBattle>();
battle.Library.Returns(library);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
move.User.BattleData.Returns(battleData);
target.BattleData.Returns(battleData);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<BlockEffect>())).IsFalse();
}
}

View File

@@ -0,0 +1,327 @@
using PkmnLib.Dynamic.Events;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Common;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static.Moves;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Bounce"/> move script.
/// Gen VII Bulbapedia behavior: "The user gains the Sky-High status on the turn this move is used, then
/// attacks on the following turn, inflicting damage with a 30% chance of paralyzing the target. While in
/// Sky-High status, the user is invulnerable to most moves, but can still be hit by Gust, Twister, Thunder,
/// and Sky Uppercut", where (from Generation V onward) for Gust and Twister "the damage dealt will be doubled".
/// </summary>
public class BounceTests
{
private static (Bounce script, IExecutingMove move, IPokemon user, ScriptSet userVolatile, IMoveChoice moveChoice,
IBattleRandom random) CreateTestSetup()
{
var script = new Bounce();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
// Use a real script set so the charge volatile added by Bounce 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 battle = Substitute.For<IBattle>();
var random = Substitute.For<IBattleRandom>();
battle.Random.Returns(random);
battle.EventHook.Returns(new EventHook());
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
user.BattleData.Returns(battleData);
return (script, move, user, userVolatile, moveChoice, random);
}
/// <summary>
/// Bulbapedia: "The user gains the Sky-High status on the turn this move is used, then attacks on the
/// following turn". 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 gains the Sky-High status on the turn this move is used".
/// The charge turn attaches the <see cref="ChargeBounceEffect"/> volatile script to the user.
/// </summary>
[Test]
public async Task PreventMove_FirstUse_AddsChargeBounceEffectToUser()
{
// Arrange
var (script, move, _, userVolatile, _, _) = CreateTestSetup();
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<ChargeBounceEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "The user gains the Sky-High status on the turn this move is used".
/// The charge turn is marked on the <see cref="IMoveChoice"/> so the <see cref="ChargeBounceEffect"/>
/// knows this choice was the charging turn.
/// </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("bounce_charge"), true);
await Assert.That(prevent).IsTrue();
}
/// <summary>
/// Bulbapedia: "then attacks on the following turn". When the user already has the
/// <see cref="ChargeBounceEffect"/> 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 ChargeBounceEffect(user));
var prevent = false;
// Act
script.PreventMove(move, ref prevent);
// Assert
await Assert.That(prevent).IsFalse();
}
/// <summary>
/// Bulbapedia: "then attacks on the following turn". Once the attack executes, the Sky-High state
/// (<see cref="ChargeBounceEffect"/>) is removed from the user.
/// </summary>
[Test]
public async Task OnBeforeMove_ChargeCompleted_RemovesChargeBounceEffect()
{
// Arrange
var (script, move, user, userVolatile, _, _) = CreateTestSetup();
userVolatile.Add(new ChargeBounceEffect(user));
// Act
script.OnBeforeMove(move);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<ChargeBounceEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "inflicting damage with a 30% chance of paralyzing the target."
/// When the effect chance roll succeeds, the target is paralyzed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_EffectChanceSucceeds_ParalyzesTarget()
{
// Arrange
var (script, move, user, _, _, random) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
random.EffectChance(30, move, target, 0).Returns(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.Received(1).SetStatus(new StringKey("paralyzed"), user);
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsTrue();
}
/// <summary>
/// Bulbapedia: "inflicting damage with 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, _, _, _, random) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
random.EffectChance(30, move, target, 0).Returns(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(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, _, _, _, random) = CreateTestSetup();
var target = Substitute.For<IPokemon>();
// 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) the secondary effect does nothing and does not throw.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NoBattleData_DoesNotParalyzeTarget()
{
// Arrange
var (script, move, user, _, _, _) = CreateTestSetup();
user.BattleData.Returns((IPokemonBattleData?)null);
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse();
}
/// <summary>
/// Bulbapedia: "While in Sky-High status, the user is invulnerable to most moves".
/// The <see cref="ChargeBounceEffect"/> added by the charge turn blocks incoming hits from moves that
/// cannot hit semi-invulnerable flying Pokémon.
/// </summary>
[Test]
public async Task BlockIncomingHit_MoveCannotHitSkyHighUser_BlocksHit()
{
// Arrange
var (script, move, user, userVolatile, _, _) = CreateTestSetup();
var prevent = false;
script.PreventMove(move, ref prevent);
await Assert.That(userVolatile.TryGet<ChargeBounceEffect>(out var effect)).IsTrue();
var incomingMove = Substitute.For<IExecutingMove>();
var incomingMoveData = Substitute.For<IMoveData>();
incomingMoveData.HasFlag(MoveFlags.HitFlying).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: "but can still be hit by Gust, Twister, Thunder, and Sky Uppercut".
/// Moves flagged as able to hit semi-invulnerable flying Pokémon are not blocked by the
/// <see cref="ChargeBounceEffect"/>.
/// </summary>
[Test]
public async Task BlockIncomingHit_MoveCanHitSkyHighUser_DoesNotBlockHit()
{
// Arrange
var (script, move, user, userVolatile, _, _) = CreateTestSetup();
var prevent = false;
script.PreventMove(move, ref prevent);
await Assert.That(userVolatile.TryGet<ChargeBounceEffect>(out var effect)).IsTrue();
var incomingMove = Substitute.For<IExecutingMove>();
var incomingMoveData = Substitute.For<IMoveData>();
incomingMoveData.HasFlag(MoveFlags.HitFlying).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 (Generation V onward): "If the user is hit by Gust or Twister while the user has the
/// Sky-High status, the damage dealt will be doubled". Gust and Twister carry the
/// <see cref="MoveFlags.EffectiveAgainstFly"/> flag, so their damage against the Sky-High user doubles.
/// </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<ChargeBounceEffect>(out var effect)).IsTrue();
var incomingMove = Substitute.For<IExecutingMove>();
var incomingMoveData = Substitute.For<IMoveData>();
incomingMoveData.HasFlag(MoveFlags.HitFlying).Returns(true);
incomingMoveData.HasFlag(MoveFlags.EffectiveAgainstFly).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 (Generation V onward): only for Gust and Twister "the damage dealt will be doubled".
/// Other moves that can hit the Sky-High user (e.g. Thunder, Sky Uppercut) deal regular damage; they do
/// not carry the <see cref="MoveFlags.EffectiveAgainstFly"/> flag.
/// </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<ChargeBounceEffect>(out var effect)).IsTrue();
var incomingMove = Substitute.For<IExecutingMove>();
var incomingMoveData = Substitute.For<IMoveData>();
incomingMoveData.HasFlag(MoveFlags.HitFlying).Returns(true);
incomingMoveData.HasFlag(MoveFlags.EffectiveAgainstFly).Returns(false);
incomingMove.UseMove.Returns(incomingMoveData);
uint damage = 100;
// Act
effect!.ChangeIncomingMoveDamage(incomingMove, user, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(100u);
}
}

View File

@@ -0,0 +1,180 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Side;
using PkmnLib.Static.Utils;
using TUnit.Assertions.AssertConditions.Throws;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="BrickBreak"/> move script.
/// Gen VII Bulbapedia behavior: "Brick Break removes Light Screen and Reflect", from Generation IV onward
/// "from the target's side of the field, even if the target is an ally", and from Generation V onwards
/// "Brick Break can also remove Aurora Veil."
/// </summary>
public class BrickBreakTests
{
/// <summary>
/// Creates a mocked Pokémon whose <see cref="IPokemonBattleData.BattleSide"/> is the given side.
/// </summary>
private static IPokemon CreatePokemonOnSide(IBattleSide side)
{
var pokemon = Substitute.For<IPokemon>();
var battleData = Substitute.For<IPokemonBattleData>();
battleData.BattleSide.Returns(side);
pokemon.BattleData.Returns(battleData);
return pokemon;
}
/// <summary>
/// Creates a fully mocked test setup where the user targets a Pokémon on the opposing side.
/// </summary>
private static (BrickBreak script, IExecutingMove move, IPokemon user, IScriptSet userSideScripts, IScriptSet
targetSideScripts) CreateTestSetup()
{
var script = new BrickBreak();
var move = Substitute.For<IExecutingMove>();
var userSideScripts = Substitute.For<IScriptSet>();
var userSide = Substitute.For<IBattleSide>();
userSide.VolatileScripts.Returns(userSideScripts);
var targetSideScripts = Substitute.For<IScriptSet>();
var targetSide = Substitute.For<IBattleSide>();
targetSide.VolatileScripts.Returns(targetSideScripts);
var user = CreatePokemonOnSide(userSide);
move.User.Returns(user);
var target = CreatePokemonOnSide(targetSide);
move.Targets.Returns(new IPokemon?[] { target });
return (script, move, user, userSideScripts, targetSideScripts);
}
/// <summary>
/// Bulbapedia (Generation IV onward): "Brick Break now removes Light Screen and Reflect from the target's
/// side of the field, even if the target is an ally." The screen effects are removed from the target's
/// side before the move hits. The script names match <see cref="ReflectEffect"/> and
/// <see cref="LightScreenEffect"/>.
/// </summary>
[Test, Arguments("reflect"), Arguments("light_screen")]
public async Task OnBeforeMove_ScreenOnTargetSide_RemovesScreenFromTargetSide(string screenScriptName)
{
// Arrange
var (script, move, _, _, targetSideScripts) = CreateTestSetup();
// Act
script.OnBeforeMove(move);
// Assert
targetSideScripts.Received(1).Remove(new StringKey(screenScriptName));
await Assert.That(targetSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsTrue();
}
/// <summary>
/// Bulbapedia (Generation V onwards): "Brick Break can also remove Aurora Veil."
/// The <see cref="AuroraVeilEffect"/> is removed from the target's side as well.
/// </summary>
[Test]
public async Task OnBeforeMove_AuroraVeilOnTargetSide_RemovesAuroraVeilFromTargetSide()
{
// Arrange
var (script, move, _, _, targetSideScripts) = CreateTestSetup();
// Act
script.OnBeforeMove(move);
// Assert
targetSideScripts.Received(1).Remove(new StringKey("aurora_veil"));
await Assert.That(targetSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsTrue();
}
/// <summary>
/// Bulbapedia (Generation IV onward): "Brick Break now removes Light Screen and Reflect from the target's
/// side of the field". When the target is an opponent, screens on the user's own side stay up.
/// </summary>
[Test]
public async Task OnBeforeMove_ScreensOnUserSide_DoesNotRemoveScreensFromUserSide()
{
// Arrange
var (script, move, _, userSideScripts, _) = CreateTestSetup();
// Act
script.OnBeforeMove(move);
// Assert
await Assert.That(userSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsFalse();
}
/// <summary>
/// Bulbapedia (Generation IV onward): "Brick Break now removes Light Screen and Reflect from the target's
/// side of the field, even if the target is an ally." An ally shares the user's side, so targeting an ally
/// removes the screens from the user's own side.
/// </summary>
[Test,
TestFailing("BrickBreak.OnBeforeMove excludes the user's own side unconditionally, so targeting an " +
"ally removes nothing (Bulbapedia: 'removes Light Screen and Reflect from the target's " +
"side of the field, even if the target is an ally')")]
public async Task OnBeforeMove_AllyTargetOnUserSide_RemovesScreensFromUserSide()
{
// Arrange
var (script, move, user, userSideScripts, _) = CreateTestSetup();
var ally = CreatePokemonOnSide(user.BattleData!.BattleSide);
move.Targets.Returns(new IPokemon?[] { ally });
// Act
script.OnBeforeMove(move);
// Assert
await Assert.That(userSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsTrue();
}
/// <summary>
/// Bulbapedia: "Brick Break removes Light Screen and Reflect [...], then inflicts damage."
/// When no screens are up there is nothing to remove; the move simply proceeds without error and the
/// sides remain unaffected.
/// </summary>
[Test]
public async Task OnBeforeMove_NoScreensPresent_LeavesSidesUnchanged()
{
// Arrange
var script = new BrickBreak();
var move = Substitute.For<IExecutingMove>();
// Real, empty script sets so the removals (if any wrongly happen) would be observable as mutations.
var userSideScripts = new ScriptSet(Substitute.For<IBattleSide>());
var targetSideScripts = new ScriptSet(Substitute.For<IBattleSide>());
var userSide = Substitute.For<IBattleSide>();
userSide.VolatileScripts.Returns(userSideScripts);
var targetSide = Substitute.For<IBattleSide>();
targetSide.VolatileScripts.Returns(targetSideScripts);
var user = CreatePokemonOnSide(userSide);
move.User.Returns(user);
var target = CreatePokemonOnSide(targetSide);
move.Targets.Returns(new IPokemon?[] { target });
// Act
script.OnBeforeMove(move);
// Assert
await Assert.That(userSideScripts.Count).IsEqualTo(0);
await Assert.That(targetSideScripts.Count).IsEqualTo(0);
}
/// <summary>
/// Technical test: without any targets (e.g. outside of battle) the script does nothing and does not throw.
/// </summary>
[Test]
public async Task OnBeforeMove_NoTargets_DoesNotThrow()
{
// Arrange
var script = new BrickBreak();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
move.User.Returns(user);
move.Targets.Returns(Array.Empty<IPokemon?>());
// Act & Assert
await Assert.That(() => script.OnBeforeMove(move)).ThrowsNothing();
}
}

View File

@@ -0,0 +1,101 @@
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="Brine"/> move script.
/// Behavior is verified against the Bulbapedia page for Brine.
/// </summary>
public class BrineTests
{
/// <summary>
/// Creates a fully mocked test setup for Brine tests, with a target whose max HP
/// (<see cref="IPokemon.BoostedStats"/>) and <see cref="IPokemon.CurrentHealth"/> are configured.
/// </summary>
private static (Brine brine, IExecutingMove move, IPokemon target) CreateTestSetup(uint maxHp, uint currentHp)
{
var brine = new Brine();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.BoostedStats.Returns(new StatisticSet<uint>(maxHp, 0, 0, 0, 0, 0));
target.CurrentHealth.Returns(currentHp);
return (brine, move, target);
}
/// <summary>
/// Bulbapedia: "Brine inflicts damage, and its power doubles to 130 when the target is at or below
/// 50% health."
/// At exactly half HP, the base power of 65 doubles to 130.
/// </summary>
[Test]
public async Task ChangeBasePower_TargetAtExactlyHalfHp_BasePowerDoubles()
{
// Arrange
var (brine, move, target) = CreateTestSetup(100, 50);
ushort basePower = 65;
// Act
brine.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)130);
}
/// <summary>
/// Bulbapedia: "its power doubles to 130 when the target is at or below 50% health."
/// Tests several HP fractions at or below half, including odd max HP values where 50% is not
/// a whole number (50/101 ≈ 49.5% is below half).
/// </summary>
[Test, Arguments(100u, 49u), Arguments(100u, 1u), Arguments(200u, 100u), Arguments(101u, 50u)]
public async Task ChangeBasePower_TargetAtOrBelowHalfHp_BasePowerDoubles(uint maxHp, uint currentHp)
{
// Arrange
var (brine, move, target) = CreateTestSetup(maxHp, currentHp);
ushort basePower = 65;
// Act
brine.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)130);
}
/// <summary>
/// Bulbapedia: "its power doubles to 130 when the target is at or below 50% health."
/// Above 50% health the power must remain unchanged, including one HP above half and odd max HP
/// values where 51/101 ≈ 50.5% is just above half.
/// </summary>
[Test, Arguments(100u, 51u), Arguments(100u, 100u), Arguments(101u, 51u), Arguments(200u, 101u)]
public async Task ChangeBasePower_TargetAboveHalfHp_BasePowerUnchanged(uint maxHp, uint currentHp)
{
// Arrange
var (brine, move, target) = CreateTestSetup(maxHp, currentHp);
ushort basePower = 65;
// Act
brine.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)65);
}
/// <summary>
/// Technical test: doubling an already very high base power must clamp to
/// <see cref="ushort.MaxValue"/> instead of overflowing.
/// </summary>
[Test]
public async Task ChangeBasePower_DoublingWouldOverflow_ClampsToMax()
{
// Arrange
var (brine, move, target) = CreateTestSetup(100, 50);
ushort basePower = ushort.MaxValue - 100;
// Act
brine.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(ushort.MaxValue);
}
}

View File

@@ -0,0 +1,243 @@
using PkmnLib.Dynamic.Events;
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Moves;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="BugBite"/> move script.
/// Behavior is verified against the Bulbapedia page for Bug Bite.
/// </summary>
public class BugBiteTests
{
private const string BerryEffectName = "test_berry_effect";
/// <summary>
/// Minimal <see cref="ItemScript"/> that records whether its effect was applied through
/// <see cref="ItemScript.OnUse"/>.
/// </summary>
private class RecordingItemScript : ItemScript
{
public RecordingItemScript(IItem item) : base(item)
{
}
public bool WasUsed { get; private set; }
/// <inheritdoc />
public override bool IsItemUsable => true;
/// <inheritdoc />
public override void OnUse(EventHook eventHook) => WasUsed = true;
}
/// <summary>
/// Creates a fully mocked test setup for BugBite tests. The battle's <see cref="ScriptResolver"/> is a
/// real resolver with a single registered item script constructor for <see cref="BerryEffectName"/>, so
/// eating a Berry runs a <see cref="RecordingItemScript"/> that the test can inspect.
/// </summary>
private static (BugBite bugBite, IExecutingMove move, IPokemon target, IHitData hitData, List<RecordingItemScript>
createdItemScripts) CreateTestSetup(IItem? targetHeldItem, bool canSteal = true)
{
var bugBite = new BugBite();
var createdItemScripts = new List<RecordingItemScript>();
var resolver = new ScriptResolver(new Dictionary<(ScriptCategory, StringKey), Func<Script>>(),
new Dictionary<StringKey, Func<IItem, ItemScript>>
{
{
new StringKey(BerryEffectName), item =>
{
var script = new RecordingItemScript(item);
createdItemScripts.Add(script);
return script;
}
},
});
var battle = Substitute.For<IBattle>();
battle.EventHook.Returns(new EventHook());
var dynamicLibrary = Substitute.For<IDynamicLibrary>();
dynamicLibrary.ScriptResolver.Returns(resolver);
battle.Library.Returns(dynamicLibrary);
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.Battle.Returns(battle);
var target = Substitute.For<IPokemon>();
target.HeldItem.Returns(targetHeldItem);
if (targetHeldItem != null && canSteal)
{
target.TryStealHeldItem(out Arg.Any<IItem?>()).Returns(x =>
{
x[0] = targetHeldItem;
return true;
});
}
else
{
target.TryStealHeldItem(out Arg.Any<IItem?>()).Returns(false);
}
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
return (bugBite, move, target, hitData, createdItemScripts);
}
/// <summary>
/// Creates a mocked Berry item whose battle effect is <see cref="BerryEffectName"/>.
/// </summary>
private static IItem CreateBerry()
{
var berry = Substitute.For<IItem>();
berry.Category.Returns(ItemCategory.Berry);
berry.BattleEffect.Returns(new SecondaryEffectImpl(-1, new StringKey(BerryEffectName),
new Dictionary<StringKey, object?>()));
return berry;
}
/// <summary>
/// Bulbapedia: "If the target is holding a Berry, the user will eat the Berry and gain its effect."
/// Eating the Berry removes it from the target via <see cref="IPokemon.ForceSetHeldItem"/>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHoldsBerry_BerryIsRemovedFromTarget()
{
// Arrange
var (bugBite, move, target, _, _) = CreateTestSetup(CreateBerry());
// Act
bugBite.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c =>
c.GetMethodInfo().Name == "ForceSetHeldItem" && c.GetArguments()[0] == null)).IsTrue();
}
/// <summary>
/// Bulbapedia: "If the target is holding a Berry, the user will eat the Berry and gain its effect."
/// The Berry's item script is executed for the user, applying its effect.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHoldsBerry_UserGainsBerryEffect()
{
// Arrange
var (bugBite, move, target, _, createdItemScripts) = CreateTestSetup(CreateBerry());
// Act
bugBite.OnSecondaryEffect(move, target, 0);
// Assert - exactly one berry script was resolved and its effect was applied
await Assert.That(createdItemScripts.Count).IsEqualTo(1);
await Assert.That(createdItemScripts[0].WasUsed).IsTrue();
}
/// <summary>
/// Bulbapedia: "If the target is holding a Berry, the user will eat the Berry and gain its effect."
/// When the Berry is successfully eaten, the secondary effect must not be marked as failed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHoldsBerry_HitDoesNotFail()
{
// Arrange
var (bugBite, move, target, hitData, _) = CreateTestSetup(CreateBerry());
// Act
bugBite.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsFalse();
}
/// <summary>
/// Bulbapedia: "If the target is holding a Berry, the user will eat the Berry and gain its effect."
/// A held item that is not a Berry is not eaten: the secondary effect fails and the target keeps
/// its item.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHoldsNonBerryItem_EffectFailsAndItemIsKept()
{
// Arrange
var nonBerry = Substitute.For<IItem>();
nonBerry.Category.Returns(ItemCategory.MiscItem);
var (bugBite, move, target, hitData, _) = CreateTestSetup(nonBerry);
// Act
bugBite.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue();
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ForceSetHeldItem")).IsFalse();
}
/// <summary>
/// Bulbapedia: "If the target is holding a Berry, the user will eat the Berry and gain its effect."
/// With no held item at all there is nothing to eat, so the secondary effect fails.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHoldsNoItem_EffectFails()
{
// Arrange
var (bugBite, move, target, hitData, _) = CreateTestSetup(null);
// Act
bugBite.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue();
}
/// <summary>
/// Bulbapedia: "Bug Bite will not consume the Berry of a target that has the Ability Sticky Hold."
/// When the Berry cannot be stolen (<see cref="IPokemon.TryStealHeldItem"/> returns false, as with
/// Sticky Hold), the target keeps its Berry and the effect fails.
/// </summary>
[Test]
public async Task OnSecondaryEffect_BerryCannotBeStolen_TargetKeepsBerry()
{
// Arrange
var (bugBite, move, target, hitData, createdItemScripts) = CreateTestSetup(CreateBerry(), false);
// Act
bugBite.OnSecondaryEffect(move, target, 0);
// Assert - the berry is not removed, its effect is not gained, and the effect fails
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ForceSetHeldItem")).IsFalse();
await Assert.That(createdItemScripts.Count).IsEqualTo(0);
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue();
}
/// <summary>
/// If the user has no <see cref="IPokemon.BattleData"/>, the script does nothing: no Berry is eaten
/// and the hit is not failed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserHasNoBattleData_DoesNothing()
{
// Arrange
var (bugBite, move, target, hitData, _) = CreateTestSetup(CreateBerry());
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
move.User.Returns(user);
// Act
bugBite.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ForceSetHeldItem")).IsFalse();
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsFalse();
}
}

View File

@@ -0,0 +1,199 @@
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Dynamic.Models;
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="BurnUp"/> move script.
/// Behavior is verified against the Bulbapedia page for Burn Up.
/// </summary>
public class BurnUpTests
{
/// <summary>
/// Creates a fully mocked test setup for Burn Up tests. The static library's type library is a real
/// <see cref="TypeLibrary"/> with "fire" and "water" registered. The user is a Water-type, optionally
/// also carrying the Fire type.
/// </summary>
private static (BurnUp burnUp, IExecutingMove move, IPokemon user, IPokemon target, IHitData hitData, TypeIdentifier
fireType) CreateTestSetup(bool userIsFireType, bool userIsFrozen = false)
{
var burnUp = new BurnUp();
var typeLibrary = new TypeLibrary();
var fireType = typeLibrary.RegisterType(new StringKey("fire"));
var waterType = typeLibrary.RegisterType(new StringKey("water"));
var staticLibrary = Substitute.For<IStaticLibrary>();
staticLibrary.Types.Returns(typeLibrary);
var dynamicLibrary = Substitute.For<IDynamicLibrary>();
dynamicLibrary.StaticLibrary.Returns(staticLibrary);
var battle = Substitute.For<IBattle>();
battle.Library.Returns(dynamicLibrary);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
user.Types.Returns(userIsFireType
? new List<TypeIdentifier> { fireType, waterType }
: new List<TypeIdentifier> { waterType });
user.HasStatus(new StringKey("frozen")).Returns(userIsFrozen);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
return (burnUp, move, user, target, hitData, fireType);
}
/// <summary>
/// Helper that checks whether <see cref="IPokemon.RemoveType"/> was called with the given type.
/// </summary>
private static bool ReceivedRemoveType(IPokemon user, TypeIdentifier type) =>
user.ReceivedCalls().Any(c =>
c.GetMethodInfo().Name == "RemoveType" && type.Equals((TypeIdentifier)c.GetArguments()[0]!));
/// <summary>
/// Bulbapedia: "If used by a Fire-type Pokémon, Burn Up will thaw out the user if it is frozen, and
/// then inflict damage to the target and cause the user to lose its Fire type."
/// </summary>
[Test]
public async Task OnSecondaryEffect_FireTypeUser_UserLosesFireType()
{
// Arrange
var (burnUp, move, user, target, _, fireType) = CreateTestSetup(true);
// Act
burnUp.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedRemoveType(user, fireType)).IsTrue();
}
/// <summary>
/// Bulbapedia: "If used by a Fire-type Pokémon, Burn Up will ... inflict damage to the target".
/// For a Fire-type user, the hit is not marked as failed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_FireTypeUser_HitDoesNotFail()
{
// Arrange
var (burnUp, move, _, target, hitData, _) = CreateTestSetup(true);
// Act
burnUp.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsFalse();
}
/// <summary>
/// Bulbapedia: "Burn Up fails if the user is not Fire-type, making the move always fail after the
/// first use until the Pokémon regains its Fire type".
/// </summary>
[Test]
public async Task OnSecondaryEffect_NonFireTypeUser_HitFails()
{
// Arrange
var (burnUp, move, _, target, hitData, _) = CreateTestSetup(false);
// Act
burnUp.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue();
}
/// <summary>
/// Bulbapedia: "Burn Up fails if the user is not Fire-type".
/// A failed Burn Up does not remove any type from the user.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NonFireTypeUser_NoTypeIsRemoved()
{
// Arrange
var (burnUp, move, user, target, _, _) = CreateTestSetup(false);
// Act
burnUp.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "RemoveType")).IsFalse();
}
/// <summary>
/// Bulbapedia: "If used by a Fire-type Pokémon, Burn Up will thaw out the user if it is frozen".
/// </summary>
[Test]
public async Task OnSecondaryEffect_FrozenFireTypeUser_UserIsThawed()
{
// Arrange
var (burnUp, move, user, target, _, _) = CreateTestSetup(true, true);
// Act
burnUp.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ClearStatus")).IsTrue();
}
/// <summary>
/// Bulbapedia: "Burn Up cannot thaw the user if the move would fail (due to the user not being
/// Fire-type)."
/// </summary>
[Test]
public async Task OnSecondaryEffect_FrozenNonFireTypeUser_UserIsNotThawed()
{
// Arrange
var (burnUp, move, user, target, _, _) = CreateTestSetup(false, true);
// Act
burnUp.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ClearStatus")).IsFalse();
}
/// <summary>
/// Bulbapedia: "Burn Up will thaw out the user if it is frozen".
/// A Fire-type user that is not frozen has no status cleared.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NonFrozenFireTypeUser_StatusIsNotCleared()
{
// Arrange
var (burnUp, move, user, target, _, _) = CreateTestSetup(true, false);
// Act
burnUp.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ClearStatus")).IsFalse();
}
/// <summary>
/// If the user has no <see cref="IPokemon.BattleData"/>, the script does nothing: no type is removed
/// and the hit is not failed.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserHasNoBattleData_DoesNothing()
{
// Arrange
var (burnUp, move, user, target, hitData, _) = CreateTestSetup(true);
user.BattleData.Returns((IPokemonBattleData?)null);
// Act
burnUp.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "RemoveType")).IsFalse();
await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsFalse();
}
}

View File

@@ -37,7 +37,7 @@ public class BatonPass : Script, IScriptOnSecondaryEffect
foreach (var script in volatileScripts)
{
if (script is not IBatonPassException)
return;
continue;
toSwitch.Volatile.Add(script);
}
}

View File

@@ -12,6 +12,11 @@ public class BellyDrum : Script, IScriptOnSecondaryEffect
move.GetHitData(target, hit).Fail();
return;
}
if (target.StatBoost.Attack >= 6)
{
move.GetHitData(target, hit).Fail();
return;
}
target.Damage(maxHealthHalved, DamageSource.Misc, forceDamage: true);
// Raising the user's Attack by 12 stages should always set it to +6.

View File

@@ -7,7 +7,7 @@ public class Bestow : Script, IScriptOnSecondaryEffect
public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit)
{
var user = move.User;
var userHeldItem = user.RemoveHeldItemForBattle();
var userHeldItem = user.HeldItem;
var targetHeldItem = target.HeldItem;
if (userHeldItem == null || targetHeldItem != null)
@@ -15,7 +15,7 @@ public class Bestow : Script, IScriptOnSecondaryEffect
move.GetHitData(target, hit).Fail();
return;
}
userHeldItem = user.RemoveHeldItemForBattle();
_ = target.ForceSetHeldItem(userHeldItem);
}
}

View File

@@ -16,11 +16,18 @@ public class Bide : Script, IScriptOnSecondaryEffect
}
bideEffect.Turns += 1;
if (bideEffect.Turns < 2)
if (bideEffect.Turns < 3)
return;
var lastPokemon = bideEffect.HitBy.LastOrDefault(x => x.BattleData?.IsOnBattlefield == true);
lastPokemon?.Damage(bideEffect.DamageTaken, DamageSource.MoveDamage);
if (bideEffect.DamageTaken == 0)
{
move.GetHitData(target, hit).Fail();
}
else
{
var lastPokemon = bideEffect.HitBy.LastOrDefault(x => x.BattleData?.IsOnBattlefield == true);
lastPokemon?.Damage(bideEffect.DamageTaken * 2, DamageSource.MoveDamage);
}
RemoveSelf();
move.User.Volatile.Remove<BideEffect>();
}
}

View File

@@ -5,9 +5,13 @@ namespace PkmnLib.Plugin.Gen7.Scripts.Moves;
[Script(ScriptCategory.Move, "block")]
public class Block : Script, IScriptOnSecondaryEffect
{
private static StringKey GhostTypeName => new("ghost");
/// <inheritdoc />
public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit)
{
if (target.Types.Any(x => x.Name == GhostTypeName))
return;
target.Volatile.Add(new BlockEffect());
}
}

View File

@@ -8,10 +8,8 @@ public class BrickBreak : Script, IScriptOnBeforeMove
/// <inheritdoc />
public void OnBeforeMove(IExecutingMove move)
{
var sides = move.User.BattleData?.Battle.Sides;
if (sides == null)
return;
var targets = move.Targets.WhereNotNull();
var sides = targets.Select(x => x.BattleData!.BattleSide).Distinct();
foreach (var side in sides)
{
side.VolatileScripts.Remove(ScriptUtils.ResolveName<ReflectEffect>());

View File

@@ -9,7 +9,7 @@ public class BanefulBunkerEffect : ProtectionEffectScript
public override void BlockIncomingHit(IExecutingMove executingMove, IPokemon target, byte hitIndex, ref bool block)
{
base.BlockIncomingHit(executingMove, target, hitIndex, ref block);
if (executingMove.UseMove.Category != MoveCategory.Status &&
if (block && executingMove.UseMove.Category != MoveCategory.Status &&
executingMove.GetHitData(target, hitIndex).IsContact)
{
executingMove.User.SetStatus(ScriptUtils.ResolveName<Status.Poisoned>(), executingMove.User);

View File

@@ -30,7 +30,7 @@ public class ChargeBounceEffect : Script, IScriptForceTurnSelection, IScriptChan
/// <inheritdoc />
public void ChangeIncomingMoveDamage(IExecutingMove move, IPokemon target, byte hit, ref uint damage)
{
if (!move.UseMove.HasFlag(MoveFlags.EffectiveAgainstFly))
if (move.UseMove.HasFlag(MoveFlags.EffectiveAgainstFly))
damage *= 2;
}