More tests, more fixes
All checks were successful
Build / Build (push) Successful in 1m57s

This commit is contained in:
2026-07-05 18:26:55 +02:00
parent 1ab15110f5
commit 6a82c20cf2
20 changed files with 1682 additions and 12 deletions

View File

@@ -296,7 +296,7 @@ public interface IPokemon : IScriptSource, IDeepCloneable
/// <summary> /// <summary>
/// Suppresses the ability of the Pokémon. /// Suppresses the ability of the Pokémon.
/// </summary> /// </summary>
void SuppressAbility(); bool SuppressAbility();
/// <summary> /// <summary>
/// Returns the currently active ability. /// Returns the currently active ability.
@@ -974,10 +974,14 @@ public class PokemonImpl : ScriptSource, IPokemon
public bool AbilitySuppressed { get; private set; } public bool AbilitySuppressed { get; private set; }
/// <inheritdoc /> /// <inheritdoc />
public void SuppressAbility() public bool SuppressAbility()
{ {
if (ActiveAbility?.CanBeChanged == false)
return false;
AbilitySuppressed = true; AbilitySuppressed = true;
AbilityScript.Clear(); AbilityScript.Clear();
return true;
} }
private (IAbility, AbilityIndex)? _abilityCache; private (IAbility, AbilityIndex)? _abilityCache;

View File

@@ -0,0 +1,118 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Camouflage"/> move script.
/// Gen VII Bulbapedia behavior: Camouflage changes the user's type based on the current battle
/// environment, and the terrain in effect takes precedence (Electric Terrain → Electric,
/// Misty Terrain → Fairy, Grassy Terrain → Grass, Psychic Terrain → Psychic).
/// </summary>
public class CamouflageTests
{
private static (Camouflage script, IExecutingMove move, IPokemon user, IBattle battle) CreateTestSetup(
string? terrainName, string environmentName)
{
var script = new Camouflage();
var library = LibraryHelpers.LoadLibrary();
var battle = Substitute.For<IBattle>();
battle.Library.Returns(library);
battle.TerrainName.Returns(terrainName == null ? null : new StringKey?(new StringKey(terrainName)));
battle.EnvironmentName.Returns(new StringKey(environmentName));
var user = Substitute.For<IPokemon>();
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
move.Battle.Returns(battle);
return (script, move, user, battle);
}
/// <summary>
/// Helper that returns the single type the user was changed to, or null when
/// <see cref="IPokemon.SetTypes"/> was never called.
/// </summary>
private static TypeIdentifier? GetSetType(IPokemon user)
{
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "SetTypes");
return call != null ? ((IReadOnlyList<TypeIdentifier>)call.GetArguments()[0]!).Single() : null;
}
/// <summary>
/// Bulbapedia (Generation VI onwards): terrain effects determine the type Camouflage grants —
/// Electric Terrain makes the user Electric-type, Misty Terrain makes it Fairy-type,
/// Grassy Terrain makes it Grass-type, and (Generation VII) Psychic Terrain makes it Psychic-type.
/// </summary>
[Test, Arguments("electric_terrain", "electric"), Arguments("misty_terrain", "fairy"),
Arguments("grassy_terrain", "grass"), Arguments("psychic_terrain", "psychic")]
public async Task OnSecondaryEffect_TerrainActive_UserBecomesTerrainType(string terrainName, string expectedType)
{
// Arrange
var (script, move, user, battle) = CreateTestSetup(terrainName, "field");
battle.Library.StaticLibrary.Types.TryGetTypeIdentifier(expectedType, out var expected);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(GetSetType(user)!.Value).IsEqualTo(expected);
}
/// <summary>
/// Bulbapedia: without a terrain in effect, the battle environment decides the type — caves make
/// the user Rock-type, mountains and beaches Ground-type, snow Ice-type, and water Water-type.
/// </summary>
[Test, Arguments("cave", "rock"), Arguments("mountain", "ground"), Arguments("beach", "ground"),
Arguments("snow", "ice"), Arguments("sea", "water"), Arguments("lake", "water")]
public async Task OnSecondaryEffect_NoTerrain_UserBecomesEnvironmentType(string environment, string expectedType)
{
// Arrange
var (script, move, user, battle) = CreateTestSetup(null, environment);
battle.Library.StaticLibrary.Types.TryGetTypeIdentifier(expectedType, out var expected);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(GetSetType(user)!.Value).IsEqualTo(expected);
}
/// <summary>
/// Bulbapedia: "On regular terrain" Camouflage changes the user's type to Normal.
/// </summary>
[Test, Arguments("field"), Arguments("forest"), Arguments("building")]
public async Task OnSecondaryEffect_RegularEnvironment_UserBecomesNormalType(string environment)
{
// Arrange
var (script, move, user, battle) = CreateTestSetup(null, environment);
battle.Library.StaticLibrary.Types.TryGetTypeIdentifier("normal", out var expected);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(GetSetType(user)!.Value).IsEqualTo(expected);
}
/// <summary>
/// Bulbapedia: Camouflage changes the type of the user, not the target — the target must be
/// left untouched.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetTypes_AreNotChanged()
{
// Arrange
var (script, move, _, _) = CreateTestSetup(null, "field");
var target = Substitute.For<IPokemon>();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetTypes")).IsFalse();
}
}

View File

@@ -0,0 +1,133 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Species;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Captivate"/> move script.
/// Gen VII Bulbapedia behavior: "Captivate lowers the Special Attack stat of the target by two stages,
/// if it is the opposite gender of the user." Gender-unknown Pokémon and Pokémon with the Oblivious
/// Ability cannot be affected, and the move fails when used by a gender-unknown Pokémon.
/// </summary>
public class CaptivateTests
{
private static (Captivate script, IExecutingMove move, IPokemon user, IPokemon target, IHitData hitData)
CreateTestSetup(Gender userGender, Gender targetGender)
{
var script = new Captivate();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.Gender.Returns(userGender);
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
target.Gender.Returns(targetGender);
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
return (script, move, user, target, hitData);
}
/// <summary>
/// Helper that checks whether a stat boost change was applied to the given Pokémon.
/// </summary>
private static bool ReceivedStatBoost(IPokemon pokemon, Statistic stat, sbyte amount) =>
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost" &&
(Statistic)c.GetArguments()[0]! == stat &&
(sbyte)c.GetArguments()[1]! == amount);
/// <summary>
/// Bulbapedia: "Captivate lowers the Special Attack stat of the target by two stages, if it is the
/// opposite gender of the user."
/// </summary>
[Test, Arguments(Gender.Male, Gender.Female), Arguments(Gender.Female, Gender.Male)]
public async Task OnSecondaryEffect_OppositeGender_LowersTargetSpecialAttackByTwoStages(Gender userGender,
Gender targetGender)
{
// Arrange
var (script, move, _, target, hitData) = CreateTestSetup(userGender, targetGender);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedStatBoost(target, Statistic.SpecialAttack, -2)).IsTrue();
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: Captivate only affects a target "if it is the opposite gender of the user" — against a
/// same-gender target the move fails and no stat is lowered.
/// </summary>
[Test, Arguments(Gender.Male), Arguments(Gender.Female)]
public async Task OnSecondaryEffect_SameGender_Fails(Gender gender)
{
// Arrange
var (script, move, _, target, hitData) = CreateTestSetup(gender, gender);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
}
/// <summary>
/// Bulbapedia: "Pokémon that are gender-unknown [...] cannot be affected."
/// </summary>
[Test]
public async Task OnSecondaryEffect_GenderlessTarget_Fails()
{
// Arrange
var (script, move, _, target, hitData) = CreateTestSetup(Gender.Male, Gender.Genderless);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
}
/// <summary>
/// Bulbapedia: "those with the Oblivious ability cannot be affected."
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHasOblivious_Fails()
{
// Arrange
var (script, move, _, target, hitData) = CreateTestSetup(Gender.Male, Gender.Female);
var ability = Substitute.For<IAbility>();
ability.Name.Returns(new StringKey("oblivious"));
target.ActiveAbility.Returns(ability);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
}
/// <summary>
/// Bulbapedia: "The move fails automatically if used by a gender-unknown Pokémon." A genderless user
/// against a gendered target must fail rather than lower the target's Special Attack.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GenderlessUser_Fails()
{
// Arrange
var (script, move, _, target, hitData) = CreateTestSetup(Gender.Genderless, Gender.Female);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
}
}

View File

@@ -0,0 +1,149 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static;
using PkmnLib.Static.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Charge"/> move script and the <see cref="ChargeEffect"/> it applies.
/// Gen VII Bulbapedia behavior: Charge doubles the power of the user's next Electric-type move,
/// and "It also raises the user's Special Defense stat by one stage."
/// </summary>
public class ChargeTests
{
private static (Charge script, IExecutingMove move, IPokemon user, IScriptSet userVolatile) CreateTestSetup()
{
var script = new Charge();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
// Use a real script set so the volatile script added by Charge 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);
return (script, move, user, userVolatile);
}
/// <summary>
/// Creates an executing move of the given type whose damage modifier can be changed by
/// <see cref="ChargeEffect.ChangeDamageModifier"/>.
/// </summary>
private static (IExecutingMove move, IPokemon target) CreateExecutingMoveOfType(string typeName)
{
var library = LibraryHelpers.LoadLibrary();
var battle = Substitute.For<IBattle>();
battle.Library.Returns(library);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
library.StaticLibrary.Types.TryGetTypeIdentifier(typeName, out var typeIdentifier);
var useMove = Substitute.For<IMoveData>();
useMove.MoveType.Returns(typeIdentifier);
var move = Substitute.For<IExecutingMove>();
move.UseMove.Returns(useMove);
return (move, target);
}
/// <summary>
/// Bulbapedia: "It also raises the user's Special Defense stat by one stage."
/// </summary>
[Test]
public async Task OnSecondaryEffect_Used_RaisesUserSpecialDefenseByOneStage()
{
// Arrange
var (script, move, user, _) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
var boost = user.ReceivedCalls().SingleOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
await Assert.That(boost).IsNotNull();
await Assert.That((Statistic)boost!.GetArguments()[0]!).IsEqualTo(Statistic.SpecialDefense);
await Assert.That((sbyte)boost.GetArguments()[1]!).IsEqualTo((sbyte)1);
await Assert.That((bool)boost.GetArguments()[2]!).IsTrue();
}
/// <summary>
/// Bulbapedia: Charge doubles the power of the user's next Electric-type move. Using Charge attaches
/// the <see cref="ChargeEffect"/> volatile script to the user.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Used_AddsChargeEffectToUser()
{
// Arrange
var (script, move, _, userVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<ChargeEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "If the user's next move is Electric-type", its power is doubled.
/// </summary>
[Test]
public async Task ChargeEffect_ElectricMove_DoublesDamageModifier()
{
// Arrange
var effect = new ChargeEffect();
var (move, target) = CreateExecutingMoveOfType("electric");
var modifier = 1.0f;
// Act
effect.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(2.0f);
}
/// <summary>
/// Bulbapedia: only Electric-type moves benefit — a non-Electric move is not boosted by Charge.
/// </summary>
[Test]
public async Task ChargeEffect_NonElectricMove_DoesNotChangeDamageModifier()
{
// Arrange
var effect = new ChargeEffect();
var (move, target) = CreateExecutingMoveOfType("water");
var modifier = 1.0f;
// Act
effect.ChangeDamageModifier(move, target, 0, ref modifier);
// Assert
await Assert.That(modifier).IsEqualTo(1.0f);
}
/// <summary>
/// Bulbapedia: Charge boosts the user's *next* Electric-type move — the effect lasts through the end
/// of the following turn and then wears off. The effect survives the end of the turn Charge was used
/// on and removes itself at the end of the next turn.
/// </summary>
[Test]
public async Task ChargeEffect_OnEndTurn_RemovesItselfAfterTheNextTurn()
{
// Arrange
var (script, move, user, userVolatile) = CreateTestSetup();
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
await Assert.That(userVolatile.TryGet<ChargeEffect>(out var effect)).IsTrue();
var battle = Substitute.For<IBattle>();
// Act & Assert - end of the turn of use: effect remains
effect!.OnEndTurn(user, battle);
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<ChargeEffect>())).IsTrue();
// Act & Assert - end of the following turn: effect is removed
effect.OnEndTurn(user, battle);
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<ChargeEffect>())).IsFalse();
}
}

View File

@@ -0,0 +1,48 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="ChipAway"/> move script.
/// Gen VII Bulbapedia behavior: "Chip Away inflicts damage, ignoring any changes to the target's Defense
/// and evasion stat stages — positive and negative."
/// </summary>
public class ChipAwayTests
{
/// <summary>
/// Bulbapedia: Chip Away inflicts damage "ignoring any changes to the target's Defense [...] stat
/// stages".
/// </summary>
[Test]
public async Task BypassDefensiveStatBoosts_Always_Bypasses()
{
// Arrange
var script = new ChipAway();
var bypass = false;
// Act
script.BypassDefensiveStatBoosts(Substitute.For<IExecutingMove>(), Substitute.For<IPokemon>(), 0, ref bypass);
// Assert
await Assert.That(bypass).IsTrue();
}
/// <summary>
/// Bulbapedia: Chip Away inflicts damage "ignoring any changes to the target's [...] evasion stat
/// stages".
/// </summary>
[Test]
public async Task BypassEvasionStatBoosts_Always_Bypasses()
{
// Arrange
var script = new ChipAway();
var bypass = false;
// Act
script.BypassEvasionStatBoosts(Substitute.For<IExecutingMove>(), Substitute.For<IPokemon>(), 0, ref bypass);
// Assert
await Assert.That(bypass).IsTrue();
}
}

View File

@@ -0,0 +1,164 @@
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
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="Conversion2"/> move script.
/// Gen VII Bulbapedia behavior: "Conversion 2 will randomly change the user's type to any type that
/// either resists or is immune to the type of the last damaging move it was hit by." From Generation V
/// onwards "Conversion 2 now targets an adjacent Pokémon" and considers that target's last used move.
/// From Generation IV onwards, "Conversion 2 will no longer change the user to any of its current types."
/// </summary>
public class Conversion2Tests
{
private static readonly IDynamicLibrary Library = LibraryHelpers.LoadLibrary();
private static (Conversion2 script, IExecutingMove move, IPokemon user, IPokemon target, IHitData hitData)
CreateTestSetup(TypeIdentifier? lastMoveType)
{
var script = new Conversion2();
var random = Substitute.For<IBattleRandom>();
random.GetInt().Returns(0);
var battle = Substitute.For<IBattle>();
battle.Library.Returns(Library);
battle.Random.Returns(random);
var userBattleData = Substitute.For<IPokemonBattleData>();
userBattleData.Battle.Returns(battle);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(userBattleData);
var targetBattleData = Substitute.For<IPokemonBattleData>();
if (lastMoveType == null)
{
targetBattleData.LastMoveChoice.Returns((IMoveChoice?)null);
}
else
{
var moveData = Substitute.For<IMoveData>();
moveData.MoveType.Returns(lastMoveType.Value);
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
var lastChoice = Substitute.For<IMoveChoice>();
lastChoice.ChosenMove.Returns(learnedMove);
targetBattleData.LastMoveChoice.Returns(lastChoice);
}
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(targetBattleData);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
return (script, move, user, target, hitData);
}
private static TypeIdentifier GetType(string name)
{
Library.StaticLibrary.Types.TryGetTypeIdentifier(name, out var type);
return type;
}
/// <summary>
/// Helper that returns the single type the user was changed to, or null when
/// <see cref="IPokemon.SetTypes"/> was never called.
/// </summary>
private static TypeIdentifier? GetSetType(IPokemon user)
{
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "SetTypes");
return call != null ? ((IReadOnlyList<TypeIdentifier>)call.GetArguments()[0]!).Single() : null;
}
/// <summary>
/// Bulbapedia: Conversion 2 changes "the user's type to any type that either resists or is immune to
/// the type of the last damaging move" the target used. The chosen type must take less than neutral
/// damage from the target's last move type.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetUsedMove_UserBecomesTypeResistingThatMove()
{
// Arrange
var normalType = GetType("normal");
var (script, move, user, target, _) = CreateTestSetup(normalType);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - the new type resists (or is immune to) Normal
var newType = GetSetType(user);
await Assert.That(newType).IsNotNull();
var effectiveness = Library.StaticLibrary.Types.GetAllEffectivenessFromAttacking(normalType)
.Single(x => x.Item1 == newType!.Value).Item2;
await Assert.That(effectiveness).IsLessThan(1f);
}
/// <summary>
/// Bulbapedia: the move works off "the type of the last damaging move it was hit by" (the target's
/// last used move from Generation V onward) — if the target has not used a move, Conversion 2 fails.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetHasNoLastMove_Fails()
{
// Arrange
var (script, move, user, target, hitData) = CreateTestSetup(null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
user.DidNotReceive().SetTypes(Arg.Any<IReadOnlyList<TypeIdentifier>>());
}
/// <summary>
/// A last move without a real type (type "none") provides no type to resist, so Conversion 2 fails.
/// </summary>
[Test]
public void OnSecondaryEffect_LastMoveHasNoType_Fails()
{
// Arrange
var (script, move, user, target, hitData) = CreateTestSetup(new TypeIdentifier(0, new StringKey("none")));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
user.DidNotReceive().SetTypes(Arg.Any<IReadOnlyList<TypeIdentifier>>());
}
/// <summary>
/// Bulbapedia (Generation IV onwards): "Conversion 2 will no longer change the user to any of its
/// current types." When the type that would be rolled is one the user already has, a different
/// resisting type must be chosen.
/// </summary>
[Test]
public async Task OnSecondaryEffect_RolledTypeIsUsersCurrentType_PicksDifferentType()
{
// Arrange
var normalType = GetType("normal");
var (script, move, user, target, _) = CreateTestSetup(normalType);
// With the mocked random always returning 0, the script deterministically picks the resisting type
// with the lowest type identifier. Give the user exactly that type.
var predictedPick = Library.StaticLibrary.Types.GetAllEffectivenessFromAttacking(normalType)
.Where(x => x.effectiveness < 1).OrderBy(x => x.type.Value).First().Item1;
user.Types.Returns([predictedPick]);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - the user must not be "changed" to a type it already has
var newType = GetSetType(user);
await Assert.That(newType).IsNotNull();
await Assert.That(newType!.Value).IsNotEqualTo(predictedPick);
}
}

View File

@@ -0,0 +1,76 @@
using PkmnLib.Dynamic.Models;
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="Conversion"/> move script.
/// Gen VII Bulbapedia behavior: "Conversion now changes the user's current type to match the type of the
/// move the user has in its first move slot (even if that move cannot be selected in battle)."
/// </summary>
public class ConversionTests
{
private static ILearnedMove CreateLearnedMoveOfType(TypeIdentifier type)
{
var moveData = Substitute.For<IMoveData>();
moveData.MoveType.Returns(type);
var learned = Substitute.For<ILearnedMove>();
learned.MoveData.Returns(moveData);
return learned;
}
private static (Conversion script, IExecutingMove move, IPokemon user, IPokemon target, IHitData hitData)
CreateTestSetup(params ILearnedMove?[] moves)
{
var script = new Conversion();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.Moves.Returns(moves);
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
return (script, move, user, target, hitData);
}
/// <summary>
/// Bulbapedia: "Conversion now changes the user's current type to match the type of the move the user
/// has in its first move slot".
/// </summary>
[Test]
public void OnSecondaryEffect_UserHasMoves_SetsUserTypeToFirstMoveSlotType()
{
// Arrange
var fireType = new TypeIdentifier(3, new StringKey("fire"));
var waterType = new TypeIdentifier(4, new StringKey("water"));
var (script, move, user, target, _) =
CreateTestSetup(CreateLearnedMoveOfType(fireType), CreateLearnedMoveOfType(waterType));
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - the first slot's type (fire) is used, not the second
user.Received(1).SetTypes(Arg.Is<IReadOnlyList<TypeIdentifier>>(t => t.Count == 1 && t[0] == fireType));
}
/// <summary>
/// Bulbapedia: the type comes from "the move the user has in its first move slot" — with no known
/// moves there is no type to copy and the move fails.
/// </summary>
[Test]
public void OnSecondaryEffect_UserHasNoMoves_Fails()
{
// Arrange
var (script, move, user, target, hitData) = CreateTestSetup(null, null, null, null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
user.DidNotReceive().SetTypes(Arg.Any<IReadOnlyList<TypeIdentifier>>());
}
}

View File

@@ -0,0 +1,118 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static.Moves;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Copycat"/> move script.
/// Gen VII Bulbapedia behavior: "Copycat causes the user to use the last move that was used in the battle
/// (even if the last move was by the user)." The move fails if no prior move occurred, and certain moves
/// (such as Counter or Copycat itself) cannot be copied.
/// </summary>
public class CopycatTests
{
private static (Copycat script, IMoveChoice choice) CreateTestSetup(string? lastMoveName)
{
var script = new Copycat();
var user = Substitute.For<IPokemon>();
var battleData = Substitute.For<IPokemonBattleData>();
if (lastMoveName == null)
{
battleData.LastMoveChoice.Returns((IMoveChoice?)null);
}
else
{
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new StringKey(lastMoveName));
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
var lastChoice = Substitute.For<IMoveChoice>();
lastChoice.ChosenMove.Returns(learnedMove);
battleData.LastMoveChoice.Returns(lastChoice);
}
user.BattleData.Returns(battleData);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
return (script, choice);
}
/// <summary>
/// Bulbapedia: "Copycat causes the user to use the last move that was used in the battle".
/// </summary>
[Test]
public async Task ChangeMove_LastMoveIsCopyable_ChangesMoveToLastMove()
{
// Arrange
var (script, choice) = CreateTestSetup("tackle");
StringKey moveName = "copycat";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
await Assert.That(moveName).IsEqualTo(new StringKey("tackle"));
choice.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: "The move fails if no prior move occurred."
/// </summary>
[Test]
public async Task ChangeMove_NoMoveUsedYet_Fails()
{
// Arrange
var (script, choice) = CreateTestSetup(null);
StringKey moveName = "copycat";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
choice.Received(1).Fail();
await Assert.That(moveName).IsEqualTo(new StringKey("copycat"));
}
/// <summary>
/// Bulbapedia lists moves Copycat cannot copy (e.g. Counter, Copycat itself, Protect). If the last
/// used move is one of those, Copycat fails.
/// </summary>
[Test, Arguments("counter"), Arguments("copycat"), Arguments("protect")]
public async Task ChangeMove_LastMoveNotCopyable_Fails(string lastMove)
{
// Arrange
var (script, choice) = CreateTestSetup(lastMove);
StringKey moveName = "copycat";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
choice.Received(1).Fail();
await Assert.That(moveName).IsEqualTo(new StringKey("copycat"));
}
/// <summary>
/// Technical test: outside of battle (no battle data) there is no last move, so Copycat fails.
/// </summary>
[Test]
public void ChangeMove_NoBattleData_Fails()
{
// Arrange
var script = new Copycat();
var user = Substitute.For<IPokemon>();
user.BattleData.Returns((IPokemonBattleData?)null);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
StringKey moveName = "copycat";
// Act
script.ChangeMove(choice, ref moveName);
// Assert
choice.Received(1).Fail();
}
}

View File

@@ -0,0 +1,181 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static.Species;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="CoreEnforcer"/> move script.
/// Gen VII Bulbapedia behavior: "Core Enforcer deals damage. If the target has already used a move or had
/// a Bag item used on it by its Trainer in the same turn, Core Enforcer also suppresses the target's
/// Ability while it remains in battle."
/// </summary>
public class CoreEnforcerTests
{
private static (CoreEnforcer script, IExecutingMove move, IMoveChoice currentChoice, IPokemon target, IHitData
hitData, IBattle battle) CreateTestSetup()
{
var script = new CoreEnforcer();
var battle = Substitute.For<IBattle>();
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var target = Substitute.For<IPokemon>();
target.BattleData.Returns(battleData);
var currentChoice = Substitute.For<IMoveChoice>();
var move = Substitute.For<IExecutingMove>();
move.MoveChoice.Returns(currentChoice);
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
return (script, move, currentChoice, target, hitData, battle);
}
private static void SetTurnChoices(IBattle battle, params ITurnChoice[] choices)
{
battle.PreviousTurnChoices.Returns(new List<IReadOnlyList<ITurnChoice>> { choices });
}
/// <summary>
/// Bulbapedia: "If the target has already used a move [...] in the same turn, Core Enforcer also
/// suppresses the target's Ability while it remains in battle."
/// </summary>
[Test]
public void OnSecondaryEffect_TargetMovedEarlierInTurn_SuppressesTargetAbility()
{
// Arrange
var (script, move, currentChoice, target, hitData, battle) = CreateTestSetup();
var targetChoice = Substitute.For<IMoveChoice>();
targetChoice.User.Returns(target);
SetTurnChoices(battle, targetChoice, currentChoice);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.Received(1).SuppressAbility();
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: the suppression also happens if the target "had a Bag item used on it by its Trainer in
/// the same turn".
/// </summary>
[Test]
public void OnSecondaryEffect_ItemUsedEarlierInTurn_SuppressesTargetAbility()
{
// Arrange
var (script, move, currentChoice, target, hitData, battle) = CreateTestSetup();
var itemChoice = Substitute.For<IItemChoice>();
itemChoice.User.Returns(target);
SetTurnChoices(battle, itemChoice, currentChoice);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.Received(1).SuppressAbility();
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: the Ability is only suppressed "If the target has already used a move [...] in the same
/// turn" — when Core Enforcer strikes before the target has acted, no suppression happens.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetActsLaterInTurn_DoesNotSuppressAbility()
{
// Arrange
var (script, move, currentChoice, target, hitData, battle) = CreateTestSetup();
var targetChoice = Substitute.For<IMoveChoice>();
targetChoice.User.Returns(target);
SetTurnChoices(battle, currentChoice, targetChoice);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.DidNotReceive().SuppressAbility();
hitData.Received(1).Fail();
}
/// <summary>
/// Bulbapedia: suppression requires the target to have already acted — as the very first action of the
/// turn, Core Enforcer never suppresses.
/// </summary>
[Test]
public void OnSecondaryEffect_CoreEnforcerIsFirstActionOfTurn_DoesNotSuppressAbility()
{
// Arrange
var (script, move, currentChoice, target, hitData, battle) = CreateTestSetup();
SetTurnChoices(battle, currentChoice);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.DidNotReceive().SuppressAbility();
hitData.Received(1).Fail();
}
/// <summary>
/// Technical test: a target without battle data (not in battle) is left untouched.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetHasNoBattleData_DoesNothing()
{
// Arrange
var (script, move, _, target, hitData, _) = CreateTestSetup();
target.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.DidNotReceive().SuppressAbility();
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: the condition is that "the target has already used a move" — an action by a different
/// Pokémon (e.g. the user's ally in a Double Battle) does not count as the target having acted.
/// </summary>
[Test]
public void OnSecondaryEffect_OnlyOtherPokemonActedEarlier_DoesNotSuppressAbility()
{
// Arrange
var (script, move, currentChoice, target, hitData, battle) = CreateTestSetup();
var allyChoice = Substitute.For<IMoveChoice>();
allyChoice.User.Returns(Substitute.For<IPokemon>());
SetTurnChoices(battle, allyChoice, currentChoice);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.DidNotReceive().SuppressAbility();
hitData.Received(1).Fail();
}
/// <summary>
/// Bulbapedia: "The move cannot suppress certain signature abilities including Multitype, Stance
/// Change, Schooling, Comatose, Shields Down, Disguise, RKS System, Battle Bond, Power Construct".
/// The script requests the suppression unconditionally; <see cref="IPokemon.SuppressAbility"/> refuses
/// it when <see cref="IAbility.CanBeChanged"/> is false, so these abilities must carry that flag in the
/// Gen7 data.
/// </summary>
[Test, Arguments("multitype"), Arguments("stance_change"), Arguments("schooling"), Arguments("comatose"),
Arguments("shields_down"), Arguments("disguise"), Arguments("rks_system"), Arguments("battle_bond"),
Arguments("power_construct")]
public async Task OnSecondaryEffect_UnsuppressableAbility_IsProtectedByAbilityData(string abilityName)
{
// Arrange
var library = LibraryHelpers.LoadLibrary();
// Assert
await Assert.That(library.StaticLibrary.Abilities.TryGet(new StringKey(abilityName), out var ability)).IsTrue();
await Assert.That(ability!.CanBeChanged).IsFalse();
}
}

View File

@@ -0,0 +1,194 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Counter"/> move script and its <see cref="CounterHelperEffect"/>.
/// Gen VII Bulbapedia behavior: Counter retaliates against the last physical move that damaged the user
/// this turn, dealing twice the damage the user received to the Pokémon that dealt it. Since Generation II
/// "Counter now affects all physical moves, dealing double damage"; it fails when the user was not hit by
/// a physical move first.
/// </summary>
public class CounterTests
{
/// <summary>
/// Creates a user whose volatile scripts contain a <see cref="CounterHelperEffect"/> that has recorded
/// an incoming physical hit of the given damage by <paramref name="attacker"/>.
/// </summary>
private static IPokemon CreateUserHitBy(IPokemon? attacker, uint damage, bool physical = true)
{
var user = Substitute.For<IPokemon>();
var userVolatile = new ScriptSet(user);
user.Volatile.Returns(userVolatile);
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var helper = new CounterHelperEffect();
userVolatile.Add(helper);
if (attacker != null)
{
var incomingMove = Substitute.For<IExecutingMove>();
var useMove = Substitute.For<IMoveData>();
useMove.Category.Returns(physical ? MoveCategory.Physical : MoveCategory.Special);
incomingMove.UseMove.Returns(useMove);
incomingMove.User.Returns(attacker);
var incomingHit = Substitute.For<IHitData>();
incomingHit.Damage.Returns(damage);
incomingMove.GetHitData(user, 0).Returns(incomingHit);
helper.OnIncomingHit(incomingMove, user, 0);
}
return user;
}
/// <summary>
/// Counter watches for incoming hits over the whole turn: at the start of the turn it attaches its
/// <see cref="CounterHelperEffect"/> to the user.
/// </summary>
[Test]
public async Task OnBeforeTurnStart_Always_AddsCounterHelperEffectToUser()
{
// Arrange
var script = new Counter();
var user = Substitute.For<IPokemon>();
var userVolatile = new ScriptSet(user);
user.Volatile.Returns(userVolatile);
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
// Act
script.OnBeforeTurnStart(choice);
// Assert
await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName<CounterHelperEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: Counter strikes back at the Pokémon that last hit the user with a physical move — the
/// move is redirected to that attacker.
/// </summary>
[Test]
public async Task ChangeTargets_UserWasHitByPhysicalMove_TargetsTheAttacker()
{
// Arrange
var script = new Counter();
var attacker = Substitute.For<IPokemon>();
var user = CreateUserHitBy(attacker, 40);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
IReadOnlyList<IPokemon?> targets = new IPokemon?[] { Substitute.For<IPokemon>() };
// Act
script.ChangeTargets(choice, ref targets);
// Assert
await Assert.That(targets.Single()).IsEqualTo(attacker);
choice.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: Counter fails when the user has not been hit by a physical move this turn.
/// </summary>
[Test]
public void ChangeTargets_UserWasNotHit_Fails()
{
// Arrange
var script = new Counter();
var user = CreateUserHitBy(null, 0);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
IReadOnlyList<IPokemon?> targets = new IPokemon?[] { Substitute.For<IPokemon>() };
// Act
script.ChangeTargets(choice, ref targets);
// Assert
choice.Received(1).Fail();
}
/// <summary>
/// Bulbapedia (Generation II onwards): "Counter now affects all physical moves, dealing double
/// damage." The damage dealt is twice the damage of the recorded hit.
/// </summary>
[Test, Arguments(40u, 80u), Arguments(1u, 2u), Arguments(123u, 246u)]
public async Task ChangeMoveDamage_UserWasHitByPhysicalMove_DealsDoubleTheDamageTaken(uint damageTaken,
uint expectedDamage)
{
// Arrange
var script = new Counter();
var attacker = Substitute.For<IPokemon>();
var user = CreateUserHitBy(attacker, damageTaken);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
uint damage = 0;
// Act
script.ChangeMoveDamage(move, attacker, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: Counter retaliates against the Pokémon that hit the user — against any other target the
/// hit fails.
/// </summary>
[Test]
public void ChangeMoveDamage_TargetIsNotTheLastAttacker_FailsHit()
{
// Arrange
var script = new Counter();
var attacker = Substitute.For<IPokemon>();
var user = CreateUserHitBy(attacker, 40);
var someoneElse = Substitute.For<IPokemon>();
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
var hitData = Substitute.For<IHitData>();
move.GetHitData(someoneElse, 0).Returns(hitData);
uint damage = 0;
// Act
script.ChangeMoveDamage(move, someoneElse, 0, ref damage);
// Assert
hitData.Received(1).Fail();
}
/// <summary>
/// Bulbapedia: only physical moves can be countered — the helper records physical hits.
/// </summary>
[Test]
public async Task CounterHelperEffect_PhysicalHit_RecordsAttackerAndDamage()
{
// Arrange
var attacker = Substitute.For<IPokemon>();
var user = CreateUserHitBy(attacker, 40);
// Assert
var helper = ((IScriptSet)user.Volatile).Get<CounterHelperEffect>();
await Assert.That(helper!.LastHitBy).IsEqualTo(attacker);
await Assert.That(helper.LastDamage).IsEqualTo(40u);
}
/// <summary>
/// Bulbapedia (Generation IV onwards): special moves cannot be countered — the helper ignores
/// non-physical hits, so Counter fails after a special hit.
/// </summary>
[Test]
public async Task CounterHelperEffect_SpecialHit_IsNotRecorded()
{
// Arrange
var attacker = Substitute.For<IPokemon>();
var user = CreateUserHitBy(attacker, 40, false);
// Assert
var helper = ((IScriptSet)user.Volatile).Get<CounterHelperEffect>();
await Assert.That(helper!.LastHitBy).IsNull();
}
}

View File

@@ -0,0 +1,92 @@
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="Covet"/> move script.
/// Gen VII Bulbapedia behavior: "Covet inflicts damage and steals the target's held item if it is holding
/// any. If the target is not holding an item or the user is already holding an item, Covet cannot steal an
/// item."
/// </summary>
public class CovetTests
{
private static (Covet script, IExecutingMove move, IPokemon user, IPokemon target) CreateTestSetup(
IItem? targetItem)
{
var script = new Covet();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
// Explicitly return null to suppress NSubstitute's auto-substitution; Covet must see an empty-handed user.
user.HeldItem.Returns((IItem?)null);
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
target.HeldItem.Returns(targetItem);
if (targetItem != null)
{
target.TryStealHeldItem(out Arg.Any<IItem?>()).Returns(x =>
{
x[0] = targetItem;
return true;
});
}
return (script, move, user, target);
}
/// <summary>
/// Bulbapedia: "Covet inflicts damage and steals the target's held item if it is holding any." The
/// item is removed from the target and ends up held by the user.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetHoldsItem_UserStealsIt()
{
// Arrange
var item = Substitute.For<IItem>();
var (script, move, user, target) = CreateTestSetup(item);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - the item is taken from the target and given to the user
target.Received(1).TryStealHeldItem(out Arg.Any<IItem?>());
user.Received(1).ForceSetHeldItem(item);
}
/// <summary>
/// Bulbapedia: "If the target is not holding an item [...], Covet cannot steal an item."
/// </summary>
[Test]
public void OnSecondaryEffect_TargetHoldsNoItem_NothingIsStolen()
{
// Arrange
var (script, move, user, target) = CreateTestSetup(null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.DidNotReceive().TryStealHeldItem(out Arg.Any<IItem?>());
user.DidNotReceive().ForceSetHeldItem(Arg.Any<IItem?>());
}
/// <summary>
/// Bulbapedia: "If [...] the user is already holding an item, Covet cannot steal an item."
/// </summary>
[Test]
public void OnSecondaryEffect_UserAlreadyHoldsItem_TargetItemIsNotStolen()
{
// Arrange
var targetItem = Substitute.For<IItem>();
var (script, move, user, target) = CreateTestSetup(targetItem);
user.HeldItem.Returns(Substitute.For<IItem>());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert - the user must not end up with the target's item
user.DidNotReceive().ForceSetHeldItem(targetItem);
}
}

View File

@@ -0,0 +1,119 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Side;
using PkmnLib.Static.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="CraftyShield"/> move script and the <see cref="CraftyShieldEffect"/> it
/// applies. Gen VII Bulbapedia behavior: "Crafty Shield protects all Pokémon on the user's side of the
/// field from status moves."
/// </summary>
public class CraftyShieldTests
{
private static (CraftyShield script, IExecutingMove move, IScriptSet sideVolatile) CreateTestSetup()
{
var script = new CraftyShield();
var side = Substitute.For<IBattleSide>();
// Use a real script set so the side script added by Crafty Shield can be inspected afterwards.
var sideVolatile = new ScriptSet(side);
side.VolatileScripts.Returns(sideVolatile);
side.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var battle = Substitute.For<IBattle>();
battle.Sides.Returns(new[] { side });
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
battleData.SideIndex.Returns((byte)0);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
return (script, move, sideVolatile);
}
private static IExecutingMove CreateExecutingMoveOfCategory(MoveCategory category)
{
var useMove = Substitute.For<IMoveData>();
useMove.Category.Returns(category);
var move = Substitute.For<IExecutingMove>();
move.UseMove.Returns(useMove);
return move;
}
/// <summary>
/// Bulbapedia: "Crafty Shield protects all Pokémon on the user's side of the field" — using the move
/// attaches the <see cref="CraftyShieldEffect"/> to the user's side.
/// </summary>
[Test]
public async Task OnSecondaryEffect_Used_AddsCraftyShieldEffectToUsersSide()
{
// Arrange
var (script, move, sideVolatile) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName<CraftyShieldEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: the shield protects "from status moves" — an incoming status move is stopped.
/// </summary>
[Test]
public async Task CraftyShieldEffect_StatusMove_IsStopped()
{
// Arrange
var effect = new CraftyShieldEffect();
var move = CreateExecutingMoveOfCategory(MoveCategory.Status);
var stop = false;
// Act
effect.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsTrue();
}
/// <summary>
/// Bulbapedia: Crafty Shield only blocks status moves — damaging moves go through it unhindered.
/// </summary>
[Test, Arguments(MoveCategory.Physical), Arguments(MoveCategory.Special)]
public async Task CraftyShieldEffect_DamagingMove_IsNotStopped(MoveCategory category)
{
// Arrange
var effect = new CraftyShieldEffect();
var move = CreateExecutingMoveOfCategory(category);
var stop = false;
// Act
effect.StopBeforeMove(move, ref stop);
// Assert
await Assert.That(stop).IsFalse();
}
/// <summary>
/// Technical test: outside of battle (no battle data) no shield can be raised and nothing happens.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NoBattleData_DoesNothing()
{
// Arrange
var (script, move, sideVolatile) = CreateTestSetup();
move.User.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert - no effect is added
await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName<CraftyShieldEffect>())).IsFalse();
}
}

View File

@@ -0,0 +1,62 @@
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="CrushGrip"/> move script.
/// Gen VII Bulbapedia behavior: "Crush Grip deals damage. Its power is greater the more HP the target
/// has." Power = 120 × (Current HP target / Max HP target), with a minimum of 1.
/// </summary>
public class CrushGripTests
{
private static (CrushGrip script, IExecutingMove move, IPokemon target) CreateTestSetup(uint currentHealth,
uint maxHealth)
{
var script = new CrushGrip();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
target.CurrentHealth.Returns(currentHealth);
target.BoostedStats.Returns(new StatisticSet<uint>(maxHealth, 1, 1, 1, 1, 1));
return (script, move, target);
}
/// <summary>
/// Bulbapedia: "Power = 120 × (Current HP target / Max HP target)" — at full HP the power is 120, and
/// it scales down (with integer truncation) as the target's HP drops.
/// </summary>
[Test, Arguments(100u, 100u, (ushort)120), Arguments(200u, 200u, (ushort)120), Arguments(50u, 100u, (ushort)60),
Arguments(75u, 100u, (ushort)90), Arguments(33u, 100u, (ushort)39), Arguments(1u, 4u, (ushort)30)]
public async Task ChangeBasePower_ScalesWithTargetCurrentHp(uint currentHealth, uint maxHealth,
ushort expectedPower)
{
// Arrange
var (script, move, target) = CreateTestSetup(currentHealth, maxHealth);
ushort basePower = 120;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expectedPower);
}
/// <summary>
/// Bulbapedia: the power varies "between 1 and 120 [...] with a minimum of 1" — even against a target
/// with almost no HP left, the power cannot drop below 1.
/// </summary>
[Test, Arguments(1u, 200u), Arguments(1u, 121u)]
public async Task ChangeBasePower_TargetAtVeryLowHp_PowerIsAtLeastOne(uint currentHealth, uint maxHealth)
{
// Arrange
var (script, move, target) = CreateTestSetup(currentHealth, maxHealth);
ushort basePower = 120;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)1);
}
}

View File

@@ -0,0 +1,212 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Curse"/> move script and the <see cref="GhostCurseEffect"/> it applies.
/// Gen VII Bulbapedia behavior — non-Ghost user: "The user's Speed stat will drop by one stage and its
/// Attack stat and Defense stat will rise by one stage each." Ghost-type user: "The user will lose half of
/// its maximum HP (rounded down) and put a curse on the target", and "A cursed Pokémon will lose ¼ of its
/// maximum HP at the end of each turn."
/// </summary>
public class CurseTests
{
private static (Curse script, IExecutingMove move, IPokemon user, IPokemon target, IScriptSet targetVolatile)
CreateTestSetup(bool userIsGhost, uint userMaxHealth = 100, uint userCurrentHealth = 100)
{
var script = new Curse();
var library = LibraryHelpers.LoadLibrary();
library.StaticLibrary.Types.TryGetTypeIdentifier("ghost", out var ghostType);
library.StaticLibrary.Types.TryGetTypeIdentifier("normal", out var normalType);
var battle = Substitute.For<IBattle>();
battle.Library.Returns(library);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
user.Types.Returns(new[] { userIsGhost ? ghostType : normalType });
user.MaxHealth.Returns(userMaxHealth);
user.CurrentHealth.Returns(userCurrentHealth);
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
// Use a real script set so the curse applied to the target can be inspected afterwards.
var targetVolatile = new ScriptSet(target);
target.Volatile.Returns(targetVolatile);
target.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
return (script, move, user, target, targetVolatile);
}
/// <summary>
/// Helper that checks whether a stat boost change was applied to the given Pokémon.
/// </summary>
private static bool ReceivedStatBoost(IPokemon pokemon, Statistic stat, sbyte amount) =>
pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost" &&
(Statistic)c.GetArguments()[0]! == stat &&
(sbyte)c.GetArguments()[1]! == amount);
/// <summary>
/// Helper to extract the damage amount from a substitute's received Damage calls.
/// </summary>
private static uint? GetDamageAmount(IPokemon pokemon)
{
var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Bulbapedia (non-Ghost user): "The user's Speed stat will drop by one stage and its Attack stat and
/// Defense stat will rise by one stage each."
/// </summary>
[Test]
public async Task OnSecondaryEffect_NonGhostUser_LowersSpeedAndRaisesAttackAndDefense()
{
// Arrange
var (script, move, user, target, _) = CreateTestSetup(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(ReceivedStatBoost(user, Statistic.Speed, -1)).IsTrue();
await Assert.That(ReceivedStatBoost(user, Statistic.Attack, 1)).IsTrue();
await Assert.That(ReceivedStatBoost(user, Statistic.Defense, 1)).IsTrue();
}
/// <summary>
/// Bulbapedia: only a Ghost-type user pays HP and curses the target — a non-Ghost user takes no damage
/// and does not curse the target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_NonGhostUser_DoesNotDamageUserOrCurseTarget()
{
// Arrange
var (script, move, user, target, targetVolatile) = CreateTestSetup(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageAmount(user)).IsNull();
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<GhostCurseEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia (Ghost-type user): "The user will lose half of its maximum HP (rounded down)". At full
/// HP that is half of its current HP as well.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GhostUserAtFullHp_UserLosesHalfItsMaximumHp()
{
// Arrange
var (script, move, user, target, _) = CreateTestSetup(true, 100, 100);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageAmount(user)!.Value).IsEqualTo(50u);
}
/// <summary>
/// Bulbapedia (Ghost-type user): the user pays HP to "put a curse on the target" — the
/// <see cref="GhostCurseEffect"/> is attached to the target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GhostUser_CursesTarget()
{
// Arrange
var (script, move, _, target, targetVolatile) = CreateTestSetup(true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<GhostCurseEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia (Ghost-type user): "The user will lose half of its maximum HP (rounded down)" — the cost
/// is based on maximum HP, not on the HP the user has left.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GhostUserAtHalfHp_UserStillLosesHalfItsMaximumHp()
{
// Arrange
var (script, move, user, target, _) = CreateTestSetup(true, 100, 50);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(GetDamageAmount(user)!.Value).IsEqualTo(50u);
}
/// <summary>
/// Bulbapedia (Ghost-type user): "If this causes the user's HP to drop to 0, the move will execute
/// fully but cause the user to faint." — the curse is still put on the target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_GhostUserFaintsFromHpCost_TargetIsStillCursed()
{
// Arrange
var (script, move, user, target, targetVolatile) = CreateTestSetup(true);
// The first read of CurrentHealth computes the HP cost; afterwards the user has fainted.
user.CurrentHealth.Returns(100u, 0u);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<GhostCurseEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "A cursed Pokémon will lose ¼ of its maximum HP at the end of each turn." At full HP
/// that is a quarter of its current HP as well.
/// </summary>
[Test]
public async Task GhostCurseEffect_OnEndTurnAtFullHp_CursedPokemonLosesQuarterOfMaximumHp()
{
// Arrange
var cursed = Substitute.For<IPokemon>();
cursed.MaxHealth.Returns(100u);
cursed.CurrentHealth.Returns(100u);
var effect = new GhostCurseEffect(cursed);
// Act
effect.OnEndTurn(Substitute.For<IScriptSource>(), Substitute.For<IBattle>());
// Assert
await Assert.That(GetDamageAmount(cursed)!.Value).IsEqualTo(25u);
}
/// <summary>
/// Bulbapedia: "A cursed Pokémon will lose ¼ of its maximum HP at the end of each turn." — the drain
/// is based on maximum HP, not on the HP the Pokémon has left.
/// </summary>
[Test]
public async Task GhostCurseEffect_OnEndTurnAtLowHp_CursedPokemonStillLosesQuarterOfMaximumHp()
{
// Arrange
var cursed = Substitute.For<IPokemon>();
cursed.MaxHealth.Returns(100u);
cursed.CurrentHealth.Returns(40u);
var effect = new GhostCurseEffect(cursed);
// Act
effect.OnEndTurn(Substitute.For<IScriptSource>(), Substitute.For<IBattle>());
// Assert
await Assert.That(GetDamageAmount(cursed)!.Value).IsEqualTo(25u);
}
}

View File

@@ -9,7 +9,7 @@ public class Captivate : Script, IScriptOnSecondaryEffect
public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit) public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit)
{ {
var user = move.User; var user = move.User;
if (target.Gender == Gender.Genderless || target.Gender == user.Gender) if (target.Gender == Gender.Genderless || target.Gender == user.Gender || user.Gender == Gender.Genderless)
{ {
move.GetHitData(target, hit).Fail(); move.GetHitData(target, hit).Fail();
return; return;

View File

@@ -16,7 +16,7 @@ public class Conversion2 : Script, IScriptOnSecondaryEffect
var typeLibrary = move.User.BattleData!.Battle.Library.StaticLibrary.Types; var typeLibrary = move.User.BattleData!.Battle.Library.StaticLibrary.Types;
// Get all types against which the last move would be not very effective // Get all types against which the last move would be not very effective
var type = typeLibrary.GetAllEffectivenessFromAttacking(lastMoveByTarget.ChosenMove.MoveData.MoveType) var type = typeLibrary.GetAllEffectivenessFromAttacking(lastMoveByTarget.ChosenMove.MoveData.MoveType)
.Where(x => x.effectiveness < 1) .Where(x => x.effectiveness < 1 && !move.User.Types.Contains(x.type))
// Shuffle them randomly, but deterministically // Shuffle them randomly, but deterministically
.OrderBy(_ => move.User.BattleData.Battle.Random.GetInt()).ThenBy(x => x.type.Value) .OrderBy(_ => move.User.BattleData.Battle.Random.GetInt()).ThenBy(x => x.type.Value)
// And grab the first one // And grab the first one

View File

@@ -11,8 +11,8 @@ public class CoreEnforcer : Script, IScriptOnSecondaryEffect
return; return;
var turnChoices = battleData.Battle.PreviousTurnChoices.Last(); var turnChoices = battleData.Battle.PreviousTurnChoices.Last();
var currentChoiceIndex = turnChoices.IndexOf(move.MoveChoice); var currentChoiceIndex = turnChoices.IndexOf(move.MoveChoice);
if (currentChoiceIndex == -1 || if (currentChoiceIndex == -1 || !turnChoices.Take(currentChoiceIndex)
!turnChoices.Take(currentChoiceIndex).Any(choice => choice is IMoveChoice or IItemChoice)) .Any(choice => choice is IMoveChoice or IItemChoice && choice.User == target))
{ {
move.GetHitData(target, hit).Fail(); move.GetHitData(target, hit).Fail();
return; return;

View File

@@ -8,7 +8,9 @@ public class Covet : Script, IScriptOnSecondaryEffect
{ {
if (target.HeldItem == null) if (target.HeldItem == null)
return; return;
if (!move.User.TryStealHeldItem(out var item)) if (move.User.HeldItem is not null)
return;
if (!target.TryStealHeldItem(out var item))
return; return;
_ = move.User.ForceSetHeldItem(item); _ = move.User.ForceSetHeldItem(item);
} }

View File

@@ -16,9 +16,7 @@ public class Curse : Script, IScriptOnSecondaryEffect
return; return;
if (move.User.Types.Contains(ghostType)) if (move.User.Types.Contains(ghostType))
{ {
move.User.Damage(move.User.CurrentHealth / 2, DamageSource.Misc, forceDamage: true); move.User.Damage(move.User.MaxHealth / 2, DamageSource.Misc, forceDamage: true);
if (move.User.CurrentHealth == 0)
return;
target.Volatile.Add(new GhostCurseEffect(target)); target.Volatile.Add(new GhostCurseEffect(target));
} }
else else

View File

@@ -13,12 +13,12 @@ public class GhostCurseEffect : Script, IScriptOnEndTurn, IAIInfoScriptExpectedE
/// <inheritdoc /> /// <inheritdoc />
public void OnEndTurn(IScriptSource owner, IBattle battle) public void OnEndTurn(IScriptSource owner, IBattle battle)
{ {
_pokemon.Damage(_pokemon.CurrentHealth / 4, DamageSource.Misc); _pokemon.Damage(_pokemon.MaxHealth / 4, DamageSource.Misc);
} }
/// <inheritdoc /> /// <inheritdoc />
public void ExpectedEndOfTurnDamage(IPokemon pokemon, ref int damage) public void ExpectedEndOfTurnDamage(IPokemon pokemon, ref int damage)
{ {
damage += (int)(_pokemon.CurrentHealth / 4f); damage += (int)(_pokemon.MaxHealth / 4f);
} }
} }