More unit tests, fixes

This commit is contained in:
2026-07-05 19:45:40 +02:00
parent db839ac214
commit 2f51e27811
18 changed files with 1499 additions and 19 deletions

View File

@@ -0,0 +1,219 @@
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.Side;
using PkmnLib.Static.Moves;
using PkmnLib.Static.Utils;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="EchoedVoice"/> move script and its <see cref="EchoedVoiceData"/> side marker.
/// Gen VII Bulbapedia behavior: "The move's base power increases by 40 each consecutive turn when used
/// repeatedly, reaching a maximum of 200. Power increases only once per round, regardless of how many
/// Pokémon deploy it."
/// </summary>
public class EchoedVoiceTests
{
/// <summary>
/// Creates a mocked move choice whose chosen move has the given name.
/// </summary>
private static IMoveChoice CreateMoveChoice(string moveName)
{
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new StringKey(moveName));
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
var choice = Substitute.For<IMoveChoice>();
choice.ChosenMove.Returns(learnedMove);
return choice;
}
/// <summary>
/// Creates a fully mocked test setup where the user's side has a real <see cref="ScriptSet"/> as its
/// volatile scripts.
/// </summary>
private static (EchoedVoice script, IExecutingMove move, IPokemon target, IScriptSet sideScripts) CreateTestSetup()
{
var script = new EchoedVoice();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet sideScripts = new ScriptSet(side);
side.VolatileScripts.Returns(sideScripts);
var battle = Substitute.For<IBattle>();
battle.Sides.Returns(new[] { side });
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
battleData.SideIndex.Returns((byte)0);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
move.User.Returns(user);
return (script, move, target, sideScripts);
}
/// <summary>
/// Bulbapedia: "The move's base power increases by 40 each consecutive turn" — on the first use there
/// is no previous turn, so the base power is not modified.
/// </summary>
[Test]
public async Task ChangeBasePower_FirstUse_BasePowerUnchanged()
{
// Arrange
var (script, move, target, _) = CreateTestSetup();
ushort basePower = 40;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)40);
}
/// <summary>
/// Bulbapedia: "The move's base power increases by 40 each consecutive turn when used repeatedly,
/// reaching a maximum of 200." Each previous consecutive use adds a stack worth 40 power: 80 on the
/// second turn, 120 on the third, 160 on the fourth, and 200 on the fifth.
/// </summary>
[Test, Arguments(1, (ushort)80), Arguments(2, (ushort)120), Arguments(3, (ushort)160), Arguments(4, (ushort)200)]
public async Task ChangeBasePower_ConsecutiveUses_PowerIncreasesByFortyPerTurn(int previousUses,
ushort expectedPower)
{
// Arrange - the move hit on the given number of previous consecutive turns
var (script, move, target, _) = CreateTestSetup();
for (var i = 0; i < previousUses; i++)
script.OnSecondaryEffect(move, target, 0);
ushort basePower = 40;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expectedPower);
}
/// <summary>
/// Bulbapedia: the base power increases "reaching a maximum of 200" — further consecutive uses beyond
/// the fifth turn do not push the power past the cap.
/// </summary>
[Test]
public async Task ChangeBasePower_MoreThanFourPreviousUses_PowerCappedAtTwoHundred()
{
// Arrange
var (script, move, target, _) = CreateTestSetup();
for (var i = 0; i < 6; i++)
script.OnSecondaryEffect(move, target, 0);
ushort basePower = 40;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)200);
}
/// <summary>
/// Bulbapedia: the power increase applies "when used repeatedly" — using the move marks the user's side
/// with <see cref="EchoedVoiceData"/> so the next turn's use is recognized as consecutive.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AddsEchoedVoiceDataToUserSide()
{
// Arrange
var (script, move, target, sideScripts) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName<EchoedVoiceData>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "The move's base power increases by 40 each consecutive turn" — each additional use
/// stacks the side marker, increasing <see cref="EchoedVoiceData.Stacks"/>.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UsedAgain_StacksEchoedVoiceData()
{
// Arrange
var (script, move, target, sideScripts) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(sideScripts.Get<EchoedVoiceData>()!.Stacks).IsEqualTo(2);
}
/// <summary>
/// Technical test: without battle data on the user (outside of battle) the base power hook does
/// nothing and does not throw.
/// </summary>
[Test]
public async Task ChangeBasePower_UserHasNoBattleData_BasePowerUnchanged()
{
// Arrange
var (script, move, target, _) = CreateTestSetup();
move.User.BattleData.Returns((IPokemonBattleData?)null);
ushort basePower = 40;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)40);
}
/// <summary>
/// Bulbapedia: the boost only applies on "consecutive" turns — when a Pokémon on the side chooses a
/// move other than Echoed Voice, the <see cref="EchoedVoiceData"/> marker removes itself, resetting
/// the power.
/// </summary>
[Test]
public async Task OnBeforeMoveChoice_DifferentMoveChosen_MarkerRemovesItself()
{
// Arrange
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet sideScripts = new ScriptSet(side);
var data = new EchoedVoiceData();
sideScripts.Add(data);
// Act
data.OnBeforeMoveChoice(CreateMoveChoice("tackle"));
// Assert
await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName<EchoedVoiceData>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "The move's base power increases by 40 each consecutive turn when used repeatedly" —
/// choosing Echoed Voice again keeps the marker (and its stacks) alive.
/// </summary>
[Test]
public async Task OnBeforeMoveChoice_EchoedVoiceChosen_MarkerStaysActive()
{
// Arrange
var side = Substitute.For<IBattleSide>();
side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet sideScripts = new ScriptSet(side);
var data = new EchoedVoiceData();
sideScripts.Add(data);
// Act
data.OnBeforeMoveChoice(CreateMoveChoice("echoed_voice"));
// Assert
await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName<EchoedVoiceData>())).IsTrue();
}
}

View File

@@ -0,0 +1,66 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using ElectricTerrainScript = PkmnLib.Plugin.Gen7.Scripts.Terrain.ElectricTerrain;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="ElectricTerrain"/> move script.
/// Gen VII Bulbapedia behavior (Generations VI to VII): Electric Terrain "envelops the field and replaces
/// the background environment and any other terrain that is already in effect."
/// </summary>
public class ElectricTerrainTests
{
private static (ElectricTerrain script, IExecutingMove move, IBattle battle) CreateTestSetup()
{
var script = new ElectricTerrain();
var move = Substitute.For<IExecutingMove>();
var battle = Substitute.For<IBattle>();
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
var user = Substitute.For<IPokemon>();
user.BattleData.Returns(battleData);
move.User.Returns(user);
return (script, move, battle);
}
/// <summary>
/// Bulbapedia: Electric Terrain "envelops the field and replaces the background environment and any
/// other terrain that is already in effect" — the move sets the battle's terrain to the
/// <see cref="ElectricTerrainScript"/> terrain script.
/// </summary>
[Test]
public void OnSecondaryEffect_SetsElectricTerrainOnBattle()
{
// Arrange
var (script, move, battle) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
battle.Received(1).SetTerrain(ScriptUtils.ResolveName<ElectricTerrainScript>());
}
/// <summary>
/// Technical test: without battle data on the user (outside of battle) the effect does nothing and
/// does not throw.
/// </summary>
[Test]
public void OnSecondaryEffect_UserHasNoBattleData_DoesNotSetTerrain()
{
// Arrange
var (script, move, battle) = CreateTestSetup();
move.User.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
// Assert
battle.DidNotReceiveWithAnyArgs().SetTerrain(default);
}
}

View File

@@ -0,0 +1,150 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.MoveVolatile;
using PkmnLib.Static;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Electrify"/> move script and its <see cref="ElectrifyEffect"/> move volatile.
/// Gen VII Bulbapedia behavior: "If the target has not yet used a move, Electrify makes the target's move
/// Electric-type for that turn, even if it is a status move."
/// </summary>
public class ElectrifyTests
{
/// <summary>
/// Creates a fully mocked test setup where the battle's choice queue is a real
/// <see cref="BattleChoiceQueue"/>. When <paramref name="targetStillHasChoice"/> is false, the queue
/// only contains another Pokémon's choice, simulating a target that already moved this turn.
/// </summary>
private static (Electrify script, IExecutingMove move, IPokemon target, IScriptSet choiceVolatile, IHitData hitData)
CreateTestSetup(bool targetStillHasChoice = true, bool hasQueue = true)
{
var script = new Electrify();
var move = Substitute.For<IExecutingMove>();
var target = Substitute.For<IPokemon>();
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
var targetChoice = Substitute.For<IMoveChoice>();
targetChoice.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet choiceVolatile = new ScriptSet(targetChoice);
targetChoice.Volatile.Returns(choiceVolatile);
targetChoice.User.Returns(target);
var queue = new BattleChoiceQueue(targetStillHasChoice ? [targetChoice] : [Substitute.For<IMoveChoice>()]);
var battle = Substitute.For<IBattle>();
battle.ChoiceQueue.Returns(hasQueue ? queue : null);
var battleData = Substitute.For<IPokemonBattleData>();
battleData.Battle.Returns(battle);
target.BattleData.Returns(battleData);
return (script, move, target, choiceVolatile, hitData);
}
/// <summary>
/// Bulbapedia: "If the target has not yet used a move, Electrify makes the target's move Electric-type
/// for that turn" — the <see cref="ElectrifyEffect"/> is attached to the target's pending move choice.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHasNotMovedYet_AddsElectrifyEffectToTargetChoice()
{
// Arrange
var (script, move, target, choiceVolatile, hitData) = CreateTestSetup();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(choiceVolatile.Contains(ScriptUtils.ResolveName<ElectrifyEffect>())).IsTrue();
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: the effect only applies "if the target has not yet used a move" — when the target has
/// no remaining choice in the queue this turn, the move fails.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetAlreadyMoved_FailsHit()
{
// Arrange
var (script, move, target, _, hitData) = CreateTestSetup(false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
}
/// <summary>
/// Technical test: when the battle has no active choice queue, the script returns without throwing and
/// without failing the hit.
/// </summary>
[Test]
public void OnSecondaryEffect_NoChoiceQueue_DoesNothing()
{
// Arrange
var (script, move, target, _, hitData) = CreateTestSetup(hasQueue: false);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: "Electrify makes the target's move Electric-type for that turn, even if it is a status
/// move" — the <see cref="ElectrifyEffect"/> changes the executed move's type to Electric.
/// </summary>
[Test]
public async Task ChangeMoveType_ChangesMoveTypeToElectric()
{
// Arrange
var effect = new ElectrifyEffect();
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);
await Assert.That(library.StaticLibrary.Types.TryGetTypeIdentifier("normal", out var normal)).IsTrue();
await Assert.That(library.StaticLibrary.Types.TryGetTypeIdentifier("electric", out var electric)).IsTrue();
TypeIdentifier? moveType = normal;
// Act
effect.ChangeMoveType(Substitute.For<IExecutingMove>(), target, 0, ref moveType);
// Assert
await Assert.That(moveType).IsNotNull();
await Assert.That(moveType!.Value).IsEqualTo(electric);
}
/// <summary>
/// Technical test: without battle data on the target the move type cannot be resolved and remains
/// unchanged.
/// </summary>
[Test]
public async Task ChangeMoveType_TargetHasNoBattleData_TypeUnchanged()
{
// Arrange
var effect = new ElectrifyEffect();
var target = Substitute.For<IPokemon>();
target.BattleData.Returns((IPokemonBattleData?)null);
TypeIdentifier? moveType = null;
// Act
effect.ChangeMoveType(Substitute.For<IExecutingMove>(), target, 0, ref moveType);
// Assert
await Assert.That(moveType).IsNull();
}
}

View File

@@ -0,0 +1,88 @@
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="ElectroBall"/> move script.
/// Gen VII Bulbapedia behavior: "Electro Ball inflicts more damage the faster the user is compared to the
/// target." The power is based on the target's Speed as a percentage of the user's Speed:
/// more than 100% (or exactly 0) → 40, 50.01%100% → 60, 33.34%50% → 80, 25.01%33.33% → 120,
/// 0.01%25% → 150. "The Speed values used take all modifiers into account (including stat stages,
/// paralysis, held items [...], and Abilities [...])."
/// </summary>
public class ElectroBallTests
{
private static (ElectroBall script, IExecutingMove move, IPokemon target) CreateTestSetup(uint userSpeed,
uint targetSpeed)
{
var script = new ElectroBall();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.BoostedStats.Returns(new StatisticSet<uint>(1, 1, 1, 1, 1, userSpeed));
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
target.BoostedStats.Returns(new StatisticSet<uint>(1, 1, 1, 1, 1, targetSpeed));
return (script, move, target);
}
/// <summary>
/// Bulbapedia: the power scales with the target's Speed as a percentage of the user's Speed —
/// more than 100% → 40, 50.01%100% → 60, 33.34%50% → 80, 25.01%33.33% → 120, 0.01%25% → 150.
/// </summary>
[Test, Arguments(100u, 150u, (ushort)40), Arguments(100u, 101u, (ushort)40), Arguments(100u, 70u, (ushort)60),
Arguments(100u, 40u, (ushort)80), Arguments(100u, 30u, (ushort)120), Arguments(100u, 20u, (ushort)150),
Arguments(500u, 100u, (ushort)150)]
public async Task ChangeBasePower_ScalesWithSpeedRatio(uint userSpeed, uint targetSpeed, ushort expectedPower)
{
// Arrange
var (script, move, target) = CreateTestSetup(userSpeed, targetSpeed);
ushort basePower = 40;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expectedPower);
}
/// <summary>
/// Bulbapedia: the power bands are inclusive of their upper boundary — a target at exactly 100% of the
/// user's Speed yields 60 power, exactly 50% yields 80, exactly 33.33% yields 120, and exactly 25%
/// yields 150.
/// </summary>
[Test, Arguments(100u, 100u, (ushort)60), Arguments(100u, 50u, (ushort)80), Arguments(300u, 100u, (ushort)120),
Arguments(400u, 100u, (ushort)150)]
public async Task ChangeBasePower_SpeedRatioAtExactBoundary_UsesHigherTier(uint userSpeed, uint targetSpeed,
ushort expectedPower)
{
// Arrange
var (script, move, target) = CreateTestSetup(userSpeed, targetSpeed);
ushort basePower = 40;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expectedPower);
}
/// <summary>
/// Bulbapedia: the power is 40 when the target's Speed is "more than 100%, or exactly 0" of the
/// user's Speed.
/// </summary>
[Test]
public async Task ChangeBasePower_TargetSpeedZero_PowerIsForty()
{
// Arrange
var (script, move, target) = CreateTestSetup(100, 0);
ushort basePower = 40;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)40);
}
}

View File

@@ -0,0 +1,126 @@
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="Embargo"/> move script and its <see cref="EmbargoEffect"/>.
/// Gen VII Bulbapedia behavior: "For five turns, the target's held item has its effects negated and its
/// Trainer cannot use items from the Bag on it."
/// </summary>
public class EmbargoTests
{
/// <summary>
/// Creates a target Pokémon whose volatile scripts are a real <see cref="ScriptSet"/>.
/// </summary>
private static (IPokemon target, IScriptSet targetVolatile) CreateTarget()
{
var target = Substitute.For<IPokemon>();
target.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet targetVolatile = new ScriptSet(target);
target.Volatile.Returns(targetVolatile);
return (target, targetVolatile);
}
/// <summary>
/// Bulbapedia: "For five turns, the target's held item has its effects negated" — using the move
/// attaches the <see cref="EmbargoEffect"/> to the target.
/// </summary>
[Test]
public async Task OnSecondaryEffect_AddsEmbargoEffectToTarget()
{
// Arrange
var script = new Embargo();
var move = Substitute.For<IExecutingMove>();
var (target, targetVolatile) = CreateTarget();
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<EmbargoEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: the target's "Trainer cannot use items from the Bag on it" and its held item is
/// negated — the effect prevents held item consumption.
/// </summary>
[Test]
public async Task PreventHeldItemConsume_EffectActive_ConsumptionPrevented()
{
// Arrange
var effect = new EmbargoEffect();
var prevented = false;
// Act
effect.PreventHeldItemConsume(Substitute.For<IPokemon>(), Substitute.For<IItem>(), ref prevented);
// Assert
await Assert.That(prevented).IsTrue();
}
/// <summary>
/// Bulbapedia: the effect lasts "for five turns" — after four end-of-turn ticks it is still active.
/// </summary>
[Test]
public async Task OnEndTurn_FourTurnsPassed_EffectStillActive()
{
// Arrange
var (target, targetVolatile) = CreateTarget();
var effect = new EmbargoEffect();
targetVolatile.Add(effect);
// Act
for (var i = 0; i < 4; i++)
effect.OnEndTurn(target, Substitute.For<IBattle>());
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<EmbargoEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: the effect lasts "for five turns" — after the fifth end-of-turn tick it removes itself.
/// </summary>
[Test]
public async Task OnEndTurn_FiveTurnsPassed_EffectRemovesItself()
{
// Arrange
var (target, targetVolatile) = CreateTarget();
var effect = new EmbargoEffect();
targetVolatile.Add(effect);
// Act
for (var i = 0; i < 5; i++)
effect.OnEndTurn(target, Substitute.For<IBattle>());
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<EmbargoEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "For five turns" — using Embargo again on an already affected target restarts the
/// five turn duration (the effect stacks by resetting its counter).
/// </summary>
[Test]
public async Task Stack_UsedAgain_DurationResetsToFiveTurns()
{
// Arrange
var (target, targetVolatile) = CreateTarget();
var effect = new EmbargoEffect();
targetVolatile.Add(effect);
for (var i = 0; i < 3; i++)
effect.OnEndTurn(target, Substitute.For<IBattle>());
// Act - Embargo is used again, restarting the duration
effect.Stack();
for (var i = 0; i < 4; i++)
effect.OnEndTurn(target, Substitute.For<IBattle>());
// Assert - only four of the renewed five turns have passed
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<EmbargoEffect>())).IsTrue();
}
}

View File

@@ -0,0 +1,230 @@
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Dynamic.ScriptHandling.Registry;
using PkmnLib.Plugin.Gen7.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="Encore"/> move script and its <see cref="EncoreEffect"/>.
/// Gen VII Bulbapedia behavior: "Encore temporarily prevents the target from using any move except the
/// last used move." It fails if the target hasn't used a move yet or its last move was Struggle, and
/// from Generation V onward the duration is "exactly 3 turns".
/// </summary>
public class EncoreTests
{
/// <summary>
/// Creates a mocked move choice whose chosen move has the given name, optionally carrying the
/// <see cref="MoveFlags.CantRepeat"/> flag.
/// </summary>
private static IMoveChoice CreateMoveChoice(string moveName, bool cantRepeat = false)
{
var moveData = Substitute.For<IMoveData>();
moveData.Name.Returns(new StringKey(moveName));
moveData.HasFlag(MoveFlags.CantRepeat).Returns(cantRepeat);
var learnedMove = Substitute.For<ILearnedMove>();
learnedMove.MoveData.Returns(moveData);
var choice = Substitute.For<IMoveChoice>();
choice.ChosenMove.Returns(learnedMove);
return choice;
}
/// <summary>
/// Creates a fully mocked test setup where the target's volatile scripts are a real
/// <see cref="ScriptSet"/> and its last used move is the given one (or none).
/// </summary>
private static (Encore script, IExecutingMove move, IPokemon target, IScriptSet targetVolatile, IHitData hitData)
CreateTestSetup(string? lastUsedMove, bool lastMoveIsReplacement = false, bool lastMoveCantRepeat = false)
{
var script = new Encore();
var move = Substitute.For<IExecutingMove>();
// Struggle is recognized through the misc library's replacement choice check, not by name.
var miscLibrary = Substitute.For<IMiscLibrary>();
var library = Substitute.For<IDynamicLibrary>();
library.MiscLibrary.Returns(miscLibrary);
var battle = Substitute.For<IBattle>();
battle.Library.Returns(library);
var target = Substitute.For<IPokemon>();
target.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet targetVolatile = new ScriptSet(target);
target.Volatile.Returns(targetVolatile);
var targetBattleData = Substitute.For<IPokemonBattleData>();
// Create the choice before the Returns call; configuring substitutes inside Returns is not allowed.
var lastMoveChoice = lastUsedMove == null ? null : CreateMoveChoice(lastUsedMove, lastMoveCantRepeat);
targetBattleData.LastMoveChoice.Returns(lastMoveChoice);
targetBattleData.Battle.Returns(battle);
target.BattleData.Returns(targetBattleData);
if (lastMoveChoice != null)
miscLibrary.IsReplacementChoice(lastMoveChoice).Returns(lastMoveIsReplacement);
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
return (script, move, target, targetVolatile, hitData);
}
/// <summary>
/// Bulbapedia: "Encore temporarily prevents the target from using any move except the last used
/// move." The target gains the <see cref="EncoreEffect"/> volatile script.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetUsedMove_AddsEncoreEffectToTarget()
{
// Arrange
var (script, move, target, targetVolatile, hitData) = CreateTestSetup("tackle");
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<EncoreEffect>())).IsTrue();
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: "It also fails if the foe hasn't used a move yet." Without a last move choice on the
/// target, the hit fails and no encore is applied.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHasNotUsedMove_FailsHit()
{
// Arrange
var (script, move, target, targetVolatile, hitData) = CreateTestSetup(null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<EncoreEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: Encore fails if the target's last move was Struggle. A last move that is a replacement
/// choice (per <see cref="IMiscLibrary.IsReplacementChoice"/>) cannot be encored.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetLastUsedStruggle_FailsHit()
{
// Arrange
var (script, move, target, targetVolatile, hitData) = CreateTestSetup("struggle", true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<EncoreEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: Encore fails "if the opponent's last move was Transform, Mimic, Sketch, Mirror Move,
/// Encore itself, Struggle, or a move with no PP remaining." These moves carry the
/// <see cref="MoveFlags.CantRepeat"/> flag in the move data, which makes Encore fail.
/// </summary>
[Test, Arguments("encore"), Arguments("transform"), Arguments("mimic"), Arguments("sketch"),
Arguments("mirror_move")]
public async Task OnSecondaryEffect_TargetLastUsedUnrepeatableMove_FailsHit(string lastUsedMove)
{
// Arrange
var (script, move, target, targetVolatile, hitData) = CreateTestSetup(lastUsedMove, lastMoveCantRepeat: true);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<EncoreEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: Encore fails "if the opponent's last move was Transform, Mimic, Sketch, Mirror Move,
/// Encore itself" — integration check: the real Gen7 move data marks exactly these moves with the
/// <see cref="MoveFlags.CantRepeat"/> flag.
/// </summary>
[Test, Arguments("encore"), Arguments("transform"), Arguments("mimic"), Arguments("sketch"),
Arguments("mirror_move")]
public async Task MoveData_UnrepeatableMove_HasCantRepeatFlag(string moveName)
{
// Arrange
var library = LibraryHelpers.LoadLibrary();
// Act
var found = library.StaticLibrary.Moves.TryGet(new StringKey(moveName), out var moveData);
// Assert
await Assert.That(found).IsTrue();
await Assert.That(moveData!.HasFlag(MoveFlags.CantRepeat)).IsTrue();
}
/// <summary>
/// Technical test: without battle data on the target (outside of battle) the effect does nothing and
/// does not throw.
/// </summary>
[Test]
public async Task OnSecondaryEffect_TargetHasNoBattleData_DoesNothing()
{
// Arrange
var (script, move, target, targetVolatile, hitData) = CreateTestSetup("tackle");
target.BattleData.Returns((IPokemonBattleData?)null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.DidNotReceive().Fail();
await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName<EncoreEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "Duration standardized to exactly 3 turns" (Generation V onward). After two
/// end-of-turn ticks the effect is still active.
/// </summary>
[Test]
public async Task OnEndTurn_TwoTurnsPassed_EffectStillActive()
{
// Arrange
var owner = Substitute.For<IPokemon>();
owner.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet ownerVolatile = new ScriptSet(owner);
var effect = new EncoreEffect(owner, new StringKey("tackle"), 3);
ownerVolatile.Add(effect);
// Act
for (var i = 0; i < 2; i++)
effect.OnEndTurn(owner, Substitute.For<IBattle>());
// Assert
await Assert.That(ownerVolatile.Contains(ScriptUtils.ResolveName<EncoreEffect>())).IsTrue();
}
/// <summary>
/// Bulbapedia: "Duration standardized to exactly 3 turns" (Generation V onward). After the third
/// end-of-turn tick the effect removes itself.
/// </summary>
[Test]
public async Task OnEndTurn_ThreeTurnsPassed_EffectRemovesItself()
{
// Arrange
var owner = Substitute.For<IPokemon>();
owner.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet ownerVolatile = new ScriptSet(owner);
var effect = new EncoreEffect(owner, new StringKey("tackle"), 3);
ownerVolatile.Add(effect);
// Act
for (var i = 0; i < 3; i++)
effect.OnEndTurn(owner, Substitute.For<IBattle>());
// Assert
await Assert.That(ownerVolatile.Contains(ScriptUtils.ResolveName<EncoreEffect>())).IsFalse();
}
}

View File

@@ -0,0 +1,81 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Endeavor"/> move script.
/// Gen VII Bulbapedia behavior: "Endeavor deals damage to the target equal to the target's remaining HP
/// minus the user's remaining HP, making them both equal. It has no effect if the target's HP is already
/// equal to or lower than the user's HP."
/// </summary>
public class EndeavorTests
{
private static (Endeavor script, IExecutingMove move, IPokemon target) CreateTestSetup(uint userHealth,
uint targetHealth)
{
var script = new Endeavor();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.CurrentHealth.Returns(userHealth);
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
target.CurrentHealth.Returns(targetHealth);
return (script, move, target);
}
/// <summary>
/// Bulbapedia: "Endeavor deals damage to the target equal to the target's remaining HP minus the
/// user's remaining HP, making them both equal."
/// </summary>
[Test, Arguments(1u, 100u, 99u), Arguments(50u, 100u, 50u), Arguments(99u, 100u, 1u), Arguments(10u, 250u, 240u)]
public async Task ChangeMoveDamage_UserHpBelowTarget_DamageEqualsHpDifference(uint userHealth, uint targetHealth,
uint expectedDamage)
{
// Arrange
var (script, move, target) = CreateTestSetup(userHealth, targetHealth);
var damage = 0u;
// Act
script.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: "It has no effect if the target's HP is already equal to or lower than the user's HP."
/// With equal HP the script does not set any damage.
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserHpEqualToTarget_DamageUnchanged()
{
// Arrange
var (script, move, target) = CreateTestSetup(100, 100);
var damage = 0u;
// Act
script.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(0u);
}
/// <summary>
/// Bulbapedia: "It has no effect if the target's HP is already equal to or lower than the user's HP."
/// With the target below the user, the damage is not modified (and does not underflow).
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserHpAboveTarget_DamageUnchanged()
{
// Arrange
var (script, move, target) = CreateTestSetup(100, 50);
var damage = 0u;
// Act
script.ChangeMoveDamage(move, target, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(0u);
}
}

View File

@@ -0,0 +1,208 @@
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="Endure"/> move script and its <see cref="EndureEffect"/>.
/// Gen VII Bulbapedia behavior: "For the rest of the turn, Endure allows the user to survive any attack
/// that would cause it to faint, leaving the user with 1 HP instead." Like other protection moves, "if it
/// or other protection moves are used consecutively, its success rate decreases."
/// </summary>
public class EndureTests
{
/// <summary>
/// Creates a fully mocked setup for driving <see cref="Endure"/>'s inherited
/// <see cref="ProtectionScript.OnSecondaryEffect"/>. The <c>target</c> of the secondary effect is the
/// Pokémon using Endure itself, as the move is self-targeted.
/// </summary>
private static (Endure script, IExecutingMove move, IPokemon target, IHitData hitData, IScriptSet volatileSet)
CreateProtectSetup(bool userMovesLast, float randomRoll)
{
var script = new Endure();
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);
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>
/// Bulbapedia: "For the rest of the turn, Endure allows the user to survive any attack that would
/// cause it to faint" — using the move attaches the <see cref="EndureEffect"/> to the user.
/// </summary>
[Test]
public async Task OnSecondaryEffect_FirstUse_AddsEndureEffect()
{
// Arrange
var (script, move, target, hitData, volatileSet) = CreateProtectSetup(false, 0.5f);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<EndureEffect>())).IsTrue();
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: Endure "always fails if the user is the last to act in the turn" — with no remaining
/// choices in the queue there is nothing left to protect against, and the move fails.
/// </summary>
[Test]
public async Task OnSecondaryEffect_UserMovesLast_FailsHit()
{
// 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.Contains(ScriptUtils.ResolveName<EndureEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "If it or other protection moves are used consecutively, its success rate decreases."
/// On the second consecutive use the success chance is 1/3, so a roll above that fails.
/// </summary>
[Test]
public async Task OnSecondaryEffect_SecondConsecutiveUseWithHighRoll_FailsHit()
{
// 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.Contains(ScriptUtils.ResolveName<EndureEffect>())).IsFalse();
}
/// <summary>
/// Bulbapedia: "If it or other protection moves are used consecutively, its success rate decreases."
/// On the second consecutive use the success chance is 1/3, so a roll below that still succeeds.
/// </summary>
[Test]
public async Task OnSecondaryEffect_SecondConsecutiveUseWithLowRoll_AddsEndureEffect()
{
// Arrange
var (script, move, target, hitData, volatileSet) = CreateProtectSetup(false, 0.2f);
volatileSet.Add(new ProtectionFailureScript { ProtectTurns = 1 });
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName<EndureEffect>())).IsTrue();
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: "Endure allows the user to survive any attack that would cause it to faint, leaving
/// the user with 1 HP instead." Damage exceeding the user's current HP is reduced to leave 1 HP.
/// </summary>
[Test, Arguments(100u, 150u, 99u), Arguments(50u, 51u, 49u), Arguments(2u, 9999u, 1u)]
public async Task ChangeIncomingDamage_LethalDamage_LeavesUserWithOneHp(uint currentHealth, uint incomingDamage,
uint expectedDamage)
{
// Arrange
var effect = new EndureEffect();
var pokemon = Substitute.For<IPokemon>();
pokemon.CurrentHealth.Returns(currentHealth);
var damage = incomingDamage;
// Act
effect.ChangeIncomingDamage(pokemon, DamageSource.MoveDamage, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: Endure lets the user "survive any attack that would cause it to faint" — a hit for
/// exactly the user's current HP would faint it, so it too must be reduced to leave 1 HP.
/// </summary>
[Test]
public async Task ChangeIncomingDamage_DamageEqualToCurrentHp_LeavesUserWithOneHp()
{
// Arrange
var effect = new EndureEffect();
var pokemon = Substitute.For<IPokemon>();
pokemon.CurrentHealth.Returns(100u);
var damage = 100u;
// Act
effect.ChangeIncomingDamage(pokemon, DamageSource.MoveDamage, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(99u);
}
/// <summary>
/// Bulbapedia: Endure only affects attacks "that would cause it to faint" — non-lethal damage passes
/// through unchanged.
/// </summary>
[Test]
public async Task ChangeIncomingDamage_NonLethalDamage_DamageUnchanged()
{
// Arrange
var effect = new EndureEffect();
var pokemon = Substitute.For<IPokemon>();
pokemon.CurrentHealth.Returns(100u);
var damage = 50u;
// Act
effect.ChangeIncomingDamage(pokemon, DamageSource.MoveDamage, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(50u);
}
/// <summary>
/// Bulbapedia: the protection lasts "for the rest of the turn" — the effect removes itself at the end
/// of the turn.
/// </summary>
[Test]
public async Task OnEndTurn_EffectRemovesItself()
{
// Arrange
var owner = Substitute.For<IPokemon>();
owner.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
IScriptSet ownerVolatile = new ScriptSet(owner);
var effect = new EndureEffect();
ownerVolatile.Add(effect);
// Act
effect.OnEndTurn(owner, Substitute.For<IBattle>());
// Assert
await Assert.That(ownerVolatile.Contains(ScriptUtils.ResolveName<EndureEffect>())).IsFalse();
}
}

View File

@@ -0,0 +1,154 @@
using PkmnLib.Dynamic.Models;
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="Entrainment"/> move script.
/// Gen VII Bulbapedia behavior: "Entrainment changes the target's Ability to match the user's." It fails
/// if both Pokémon already share the same Ability, if the target's Ability cannot be changed (e.g.
/// Truant, Multitype, Stance Change, Comatose, Disguise), or if the user's Ability cannot be copied
/// (e.g. Trace, Illusion, Imposter, Power Construct).
/// </summary>
public class EntrainmentTests
{
/// <summary>
/// Creates a mocked ability that reports the given flags as set.
/// </summary>
private static IAbility CreateAbility(params string[] flags)
{
var ability = Substitute.For<IAbility>();
foreach (var flag in flags)
ability.HasFlag(new StringKey(flag)).Returns(true);
return ability;
}
private static (Entrainment script, IExecutingMove move, IPokemon target, IHitData hitData) CreateTestSetup(
IAbility? userAbility, IAbility? targetAbility)
{
var script = new Entrainment();
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.ActiveAbility.Returns(userAbility);
move.User.Returns(user);
target.ActiveAbility.Returns(targetAbility);
return (script, move, target, hitData);
}
/// <summary>
/// Bulbapedia: "Entrainment changes the target's Ability to match the user's."
/// </summary>
[Test]
public void OnSecondaryEffect_DifferentAbilities_ChangesTargetAbilityToUsers()
{
// Arrange
var userAbility = CreateAbility();
var (script, move, target, hitData) = CreateTestSetup(userAbility, CreateAbility());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
target.Received(1).ChangeAbility(userAbility);
hitData.DidNotReceive().Fail();
}
/// <summary>
/// Bulbapedia: "The move fails if both Pokémon already share the same Ability."
/// </summary>
[Test]
public void OnSecondaryEffect_SameAbility_FailsHit()
{
// Arrange
var sharedAbility = CreateAbility();
var (script, move, target, hitData) = CreateTestSetup(sharedAbility, sharedAbility);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
target.DidNotReceiveWithAnyArgs().ChangeAbility(default!);
}
/// <summary>
/// Bulbapedia: "The move fails if the user has: Trace, Forecast, Flower Gift, Zen Mode, Illusion,
/// Imposter, [...]" — abilities that cannot be copied prevent Entrainment from working.
/// </summary>
[Test]
public void OnSecondaryEffect_UserAbilityCantBeCopied_FailsHit()
{
// Arrange
var userAbility = CreateAbility("cant_be_copied");
var (script, move, target, hitData) = CreateTestSetup(userAbility, CreateAbility());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
target.DidNotReceiveWithAnyArgs().ChangeAbility(default!);
}
/// <summary>
/// Bulbapedia: "The move cannot affect Pokémon with these Abilities: Truant, Multitype, Stance
/// Change, [...]" — a target whose ability cannot be changed prevents Entrainment from working.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetAbilityCantBeChanged_FailsHit()
{
// Arrange
var targetAbility = CreateAbility("cant_be_changed");
var (script, move, target, hitData) = CreateTestSetup(CreateAbility(), targetAbility);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
target.DidNotReceiveWithAnyArgs().ChangeAbility(default!);
}
/// <summary>
/// Bulbapedia: "Entrainment changes the target's Ability to match the user's" — without a user
/// ability there is nothing to copy, so the move fails.
/// </summary>
[Test]
public void OnSecondaryEffect_UserHasNoAbility_FailsHit()
{
// Arrange
var (script, move, target, hitData) = CreateTestSetup(null, CreateAbility());
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
target.DidNotReceiveWithAnyArgs().ChangeAbility(default!);
}
/// <summary>
/// Technical test: a target without an active ability cannot have it validated for replacement, so
/// the move fails rather than throwing.
/// </summary>
[Test]
public void OnSecondaryEffect_TargetHasNoAbility_FailsHit()
{
// Arrange
var (script, move, target, hitData) = CreateTestSetup(CreateAbility(), null);
// Act
script.OnSecondaryEffect(move, target, 0);
// Assert
hitData.Received(1).Fail();
target.DidNotReceiveWithAnyArgs().ChangeAbility(default!);
}
}

View File

@@ -0,0 +1,63 @@
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="Eruption"/> move script.
/// Gen VII Bulbapedia behavior: "Eruption deals damage. It has 150 base power at maximum HP, but its base
/// power decreases linearly with the user's remaining HP", following ⌊150 × HPcurrent / HPmax⌋ with a
/// minimum of 1.
/// </summary>
public class EruptionTests
{
private static (Eruption script, IExecutingMove move, IPokemon target) CreateTestSetup(uint currentHealth,
uint maxHealth)
{
var script = new Eruption();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.CurrentHealth.Returns(currentHealth);
user.BoostedStats.Returns(new StatisticSet<uint>(maxHealth, 1, 1, 1, 1, 1));
move.User.Returns(user);
var target = Substitute.For<IPokemon>();
return (script, move, target);
}
/// <summary>
/// Bulbapedia: "It has 150 base power at maximum HP, but its base power decreases linearly with the
/// user's remaining HP" — power = ⌊150 × HPcurrent / HPmax⌋, with integer truncation.
/// </summary>
[Test, Arguments(100u, 100u, (ushort)150), Arguments(300u, 300u, (ushort)150), Arguments(50u, 100u, (ushort)75),
Arguments(75u, 100u, (ushort)112), Arguments(33u, 100u, (ushort)49), Arguments(150u, 300u, (ushort)75)]
public async Task ChangeBasePower_ScalesWithUserCurrentHp(uint currentHealth, uint maxHealth, ushort expectedPower)
{
// Arrange
var (script, move, target) = CreateTestSetup(currentHealth, maxHealth);
ushort basePower = 150;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo(expectedPower);
}
/// <summary>
/// Bulbapedia: "if this calculation yields a value below 1, the move's power becomes 1 instead."
/// </summary>
[Test, Arguments(1u, 300u), Arguments(1u, 151u)]
public async Task ChangeBasePower_UserAtVeryLowHp_PowerIsAtLeastOne(uint currentHealth, uint maxHealth)
{
// Arrange
var (script, move, target) = CreateTestSetup(currentHealth, maxHealth);
ushort basePower = 150;
// Act
script.ChangeBasePower(move, target, 0, ref basePower);
// Assert
await Assert.That(basePower).IsEqualTo((ushort)1);
}
}

View File

@@ -0,0 +1,69 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
/// <summary>
/// Tests for the <see cref="Explosion"/> move script.
/// Gen VII Bulbapedia behavior: "the user faints upon using this move", and from Generation VI onwards
/// "Explosion returns to damaging the target before the user faints."
/// </summary>
public class ExplosionTests
{
private static (Explosion script, IExecutingMove move, IPokemon user) CreateTestSetup(uint currentHealth)
{
var script = new Explosion();
var move = Substitute.For<IExecutingMove>();
var user = Substitute.For<IPokemon>();
user.CurrentHealth.Returns(currentHealth);
move.User.Returns(user);
return (script, move, user);
}
/// <summary>
/// Helper to extract the damage amount from the user's received Damage calls.
/// </summary>
private static uint? GetDamageAmount(IPokemon user)
{
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
return call != null ? (uint)call.GetArguments()[0]! : null;
}
/// <summary>
/// Bulbapedia: "the user faints upon using this move" — after the hits are dealt, the user takes at
/// least its full current HP in damage, guaranteeing it faints.
/// </summary>
[Test]
public async Task OnAfterHits_UserTakesAtLeastItsCurrentHealthInDamage()
{
// Arrange
var (script, move, user) = CreateTestSetup(100);
// Act
script.OnAfterHits(move, Substitute.For<IPokemon>());
// Assert
var damage = GetDamageAmount(user);
await Assert.That(damage).IsNotNull();
await Assert.That(damage!.Value).IsGreaterThanOrEqualTo(100u);
}
/// <summary>
/// Bulbapedia: "Sturdy, Focus Band, and Focus Sash do not prevent user fainting." The self-inflicted
/// faint is indirect damage (<see cref="DamageSource.Misc"/>), not move damage, so hold-on effects
/// that watch for move damage do not apply.
/// </summary>
[Test]
public async Task OnAfterHits_UsesMiscDamageSource()
{
// Arrange
var (script, move, user) = CreateTestSetup(100);
// Act
script.OnAfterHits(move, Substitute.For<IPokemon>());
// Assert
var call = user.ReceivedCalls().First(c => c.GetMethodInfo().Name == "Damage");
await Assert.That((DamageSource)call.GetArguments()[1]!).IsEqualTo(DamageSource.Misc);
}
}

View File

@@ -4,6 +4,7 @@ public static class MoveFlags
{
public static readonly StringKey Ballistics = "ballistics";
public static readonly StringKey Bite = "bite";
public static readonly StringKey CantRepeat = "cant_repeat";
public static readonly StringKey Charge = "charge";
public static readonly StringKey Contact = "contact";
public static readonly StringKey Dance = "dance";

View File

@@ -3196,7 +3196,8 @@
"mirror",
"ignore_substitute",
"mental",
"limit_move_choice"
"limit_move_choice",
"cant_repeat"
],
"effect": {
"name": "encore"
@@ -6968,7 +6969,8 @@
"category": "status",
"flags": [
"protect",
"ignore_substitute"
"ignore_substitute",
"cant_repeat"
],
"effect": {
"name": "mimic"
@@ -7054,7 +7056,9 @@
"priority": 0,
"target": "Any",
"category": "status",
"flags": [],
"flags": [
"cant_repeat"
],
"effect": {
"name": "mirror_move"
}
@@ -10033,7 +10037,8 @@
"target": "Any",
"category": "status",
"flags": [
"ignore_substitute"
"ignore_substitute",
"cant_repeat"
],
"effect": {
"name": "sketch"
@@ -12100,7 +12105,9 @@
"priority": 0,
"target": "Any",
"category": "status",
"flags": [],
"flags": [
"cant_repeat"
],
"effect": {
"name": "transform"
}

View File

@@ -3,10 +3,9 @@ using PkmnLib.Plugin.Gen7.Scripts.Side;
namespace PkmnLib.Plugin.Gen7.Scripts.Moves;
[Script(ScriptCategory.Move, "echoed_voice")]
public class EchoedVoice : Script, IScriptOnSecondaryEffect, IScriptChangeDamageModifier
public class EchoedVoice : Script, IScriptOnSecondaryEffect, IScriptChangeBasePower
{
/// <inheritdoc />
public void ChangeDamageModifier(IExecutingMove move, IPokemon target, byte hit, ref float modifier)
public void ChangeBasePower(IExecutingMove move, IPokemon target, byte hit, ref ushort basePower)
{
var battleData = move.User.BattleData;
if (battleData == null)
@@ -16,7 +15,8 @@ public class EchoedVoice : Script, IScriptOnSecondaryEffect, IScriptChangeDamage
if (echoedVoiceData == null)
return;
modifier *= 2;
var newBasePower = basePower + (ushort)(echoedVoiceData.Stacks * 40);
basePower = (ushort)Math.Min(newBasePower, 200);
}
/// <inheritdoc />

View File

@@ -9,14 +9,19 @@ public class ElectroBall : Script, IScriptChangeBasePower
var user = move.User;
var targetSpeed = target.BoostedStats.Speed;
var userSpeed = user.BoostedStats.Speed;
if (targetSpeed == 0)
{
basePower = 40;
return;
}
var ratio = (float)userSpeed / targetSpeed;
basePower = ratio switch
{
> 4 => 150,
> 3 => 120,
> 2 => 80,
> 1 => 60,
>= 4 => 150,
>= 3 => 120,
>= 2 => 80,
>= 1 => 60,
_ => 40,
};
}

View File

@@ -1,3 +1,4 @@
using System.Collections.Frozen;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
namespace PkmnLib.Plugin.Gen7.Scripts.Moves;
@@ -13,7 +14,8 @@ public class Encore : Script, IScriptOnSecondaryEffect
return;
var lastMove = target.BattleData?.LastMoveChoice;
if (lastMove == null || battle.Library.MiscLibrary.IsReplacementChoice(lastMove))
if (lastMove == null || battle.Library.MiscLibrary.IsReplacementChoice(lastMove) ||
lastMove.ChosenMove.MoveData.HasFlag(MoveFlags.CantRepeat))
{
move.GetHitData(target, hit).Fail();
return;

View File

@@ -6,7 +6,7 @@ public class EndureEffect : Script, IScriptOnEndTurn, IScriptChangeIncomingDamag
/// <inheritdoc />
public void ChangeIncomingDamage(IPokemon pokemon, DamageSource source, ref uint damage)
{
if (damage > pokemon.CurrentHealth)
if (damage >= pokemon.CurrentHealth)
damage = pokemon.CurrentHealth - 1;
}

View File

@@ -4,11 +4,22 @@ namespace PkmnLib.Plugin.Gen7.Scripts.Side;
/// Just here to indicate that a Pokemon on this side has used Echoed Voice.
/// </summary>
[Script(ScriptCategory.Side, "echoed_voice_data")]
public class EchoedVoiceData : Script, IScriptOnEndTurn
public class EchoedVoiceData : Script, IScriptOnBeforeMoveChoice, IScriptStack
{
/// <inheritdoc />
public void OnEndTurn(IScriptSource owner, IBattle battle)
private int _stacks = 1;
public int Stacks => _stacks;
public void OnBeforeMoveChoice(IMoveChoice moveChoice)
{
if (moveChoice.ChosenMove.MoveData.Name != "echoed_voice")
{
RemoveSelf();
}
}
public void Stack()
{
_stacks++;
}
}