Files
PkmnLib.NET/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/MetalBurstTests.cs
Deukhoofd d2a82b5fe3
All checks were successful
Build / Build (push) Successful in 3m12s
Many more tests and fixes
2026-08-27 17:11:12 +02:00

301 lines
12 KiB
C#

using PkmnLib.Dynamic.Models;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.ScriptHandling;
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="MetalBurst"/> script, which implements Metal Burst.
/// Behavior is verified against the Bulbapedia page for Metal Burst (Generation VII).
/// </summary>
public class MetalBurstTests
{
/// <summary>
/// Creates a Metal Burst user whose damage-tracking volatile has been installed through
/// <see cref="MetalBurst.OnBeforeTurnStart"/>. The user's <see cref="IPokemon.Volatile"/> is a real
/// <see cref="ScriptSet"/> so the internal tracker script can actually be stored and retrieved.
/// </summary>
private static (MetalBurst metalBurst, IPokemon user, IScriptSet userVolatile) CreateUserWithTracker()
{
var metalBurst = new MetalBurst();
var user = Substitute.For<IPokemon>();
var userVolatile = new ScriptSet(user);
user.Volatile.Returns(userVolatile);
// ScriptSet.Add runs the IScriptPreventVolatileAdd hook pass over the owner's scripts; give the mock a
// real (empty) iterator so that pass can run.
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
var choice = Substitute.For<ITurnChoice>();
choice.User.Returns(user);
metalBurst.OnBeforeTurnStart(choice);
return (metalBurst, user, userVolatile);
}
/// <summary>
/// Creates an opposing Pokémon with a real (empty) <see cref="ScriptSet"/> as its volatile collection.
/// </summary>
private static IPokemon CreateOpponent()
{
var opponent = Substitute.For<IPokemon>();
opponent.Volatile.Returns(new ScriptSet(opponent));
return opponent;
}
/// <summary>
/// Simulates the user being hit by an opponent's move; the damage tracker installed on the user observes
/// this through its <see cref="IScriptOnIncomingHit"/> hook.
/// </summary>
private static void HitUser(IPokemon user, IPokemon attacker, uint damage,
MoveCategory category = MoveCategory.Physical, byte hit = 0)
{
var attackMove = Substitute.For<IExecutingMove>();
attackMove.User.Returns(attacker);
var moveData = Substitute.For<IMoveData>();
moveData.Category.Returns(category);
attackMove.UseMove.Returns(moveData);
var hitData = Substitute.For<IHitData>();
hitData.Damage.Returns(damage);
attackMove.GetHitData(user, hit).Returns(hitData);
var tracker = user.Volatile.Select(c => c.Script).OfType<IScriptOnIncomingHit>().Single();
tracker.OnIncomingHit(attackMove, user, hit);
}
/// <summary>
/// Creates the executing Metal Burst move used by <paramref name="user"/> against <paramref name="target"/>.
/// </summary>
private static (IExecutingMove move, IHitData hitData) CreateMetalBurstMove(IPokemon user, IPokemon target)
{
var move = Substitute.For<IExecutingMove>();
move.User.Returns(user);
var hitData = Substitute.For<IHitData>();
move.GetHitData(target, 0).Returns(hitData);
return (move, hitData);
}
/// <summary>
/// Helper that checks whether <see cref="IHitData.Fail"/> was called on the hit data.
/// </summary>
private static bool ReceivedFail(IHitData hitData) =>
hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail");
/// <summary>
/// Bulbapedia: "Metal Burst returns 1.5 times the damage dealt by the foe's last attack."
/// To know that damage, the script installs a damage-tracking volatile on the user just before the turn
/// starts.
/// </summary>
[Test]
public async Task OnBeforeTurnStart_Called_AddsDamageTrackerToUserVolatile()
{
// Arrange & Act
var (_, _, userVolatile) = CreateUserWithTracker();
// Assert
await Assert.That(userVolatile.Count).IsEqualTo(1);
}
/// <summary>
/// Bulbapedia: "Metal Burst returns 1.5 times the damage dealt by the foe's last attack."
/// The user is hit by the target, then retaliates with Metal Burst against that target; the damage must be
/// 1.5 times the damage taken (fractions truncated).
/// </summary>
[Test, Arguments(100u, 150u), Arguments(101u, 151u), Arguments(33u, 49u), Arguments(1u, 1u)]
public async Task ChangeMoveDamage_UserWasHitByTarget_DealsOneAndAHalfTimesDamageTaken(uint damageTaken,
uint expectedDamage)
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var attacker = CreateOpponent();
HitUser(user, attacker, damageTaken);
var (move, _) = CreateMetalBurstMove(user, attacker);
// Act
uint damage = 0;
metalBurst.ChangeMoveDamage(move, attacker, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(expectedDamage);
}
/// <summary>
/// Bulbapedia: "Metal Burst returns 1.5 times the damage dealt by the foe's last attack."
/// When the user was hit by the target this turn, Metal Burst succeeds, so the hit must not be failed.
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserWasHitByTarget_DoesNotFailHit()
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var attacker = CreateOpponent();
HitUser(user, attacker, 100);
var (move, hitData) = CreateMetalBurstMove(user, attacker);
// Act
uint damage = 0;
metalBurst.ChangeMoveDamage(move, attacker, 0, ref damage);
// Assert
await Assert.That(ReceivedFail(hitData)).IsFalse();
}
/// <summary>
/// Bulbapedia: "if the user acts before it is hit by an opponent's damaging move, Metal Burst will fail."
/// If no damaging move hit the user this turn, the hit is failed and no damage is dealt.
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserNotHitThisTurn_FailsHit()
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var opponent = CreateOpponent();
var (move, hitData) = CreateMetalBurstMove(user, opponent);
// Act
uint damage = 0;
metalBurst.ChangeMoveDamage(move, opponent, 0, ref damage);
// Assert
await Assert.That(ReceivedFail(hitData)).IsTrue();
await Assert.That(damage).IsEqualTo(0u);
}
/// <summary>
/// Bulbapedia: "if the user acts before it is hit by an opponent's damaging move, Metal Burst will fail."
/// A status move is not a damaging move, so being targeted by only a status move must still make Metal
/// Burst fail.
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserOnlyHitByStatusMove_FailsHit()
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var attacker = CreateOpponent();
HitUser(user, attacker, 0, MoveCategory.Status);
var (move, hitData) = CreateMetalBurstMove(user, attacker);
// Act
uint damage = 0;
metalBurst.ChangeMoveDamage(move, attacker, 0, ref damage);
// Assert
await Assert.That(ReceivedFail(hitData)).IsTrue();
}
/// <summary>
/// Bulbapedia: "Only the last damage taken will be counted, if the user is hit by a multi-hit move, only
/// the damage from the final hit will be counted."
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserHitByMultiHitMove_OnlyFinalHitCounted()
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var attacker = CreateOpponent();
HitUser(user, attacker, 40, hit: 0);
HitUser(user, attacker, 100, hit: 1);
var (move, _) = CreateMetalBurstMove(user, attacker);
// Act
uint damage = 0;
metalBurst.ChangeMoveDamage(move, attacker, 0, ref damage);
// Assert - 1.5 times the final hit's 100 damage, not the earlier 40.
await Assert.That(damage).IsEqualTo(150u);
}
/// <summary>
/// Bulbapedia: "In battles involving multiple Pokémon, Metal Burst will hit the last opponent that dealt
/// damage to the user".
/// When two opponents hit the user, retaliating against the last one uses that opponent's damage.
/// </summary>
[Test]
public async Task ChangeMoveDamage_UserHitByTwoOpponents_UsesDamageOfLastAttacker()
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var firstAttacker = CreateOpponent();
var lastAttacker = CreateOpponent();
HitUser(user, firstAttacker, 50);
HitUser(user, lastAttacker, 80);
var (move, _) = CreateMetalBurstMove(user, lastAttacker);
// Act
uint damage = 0;
metalBurst.ChangeMoveDamage(move, lastAttacker, 0, ref damage);
// Assert
await Assert.That(damage).IsEqualTo(120u);
}
/// <summary>
/// Bulbapedia: "Metal Burst will hit the last opponent that dealt damage to the user".
/// Hitting an opponent that is not the last Pokémon that damaged the user must fail.
/// </summary>
[Test]
public async Task ChangeMoveDamage_TargetIsNotLastAttacker_FailsHit()
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var firstAttacker = CreateOpponent();
var lastAttacker = CreateOpponent();
HitUser(user, firstAttacker, 50);
HitUser(user, lastAttacker, 80);
var (move, hitData) = CreateMetalBurstMove(user, firstAttacker);
// Act
uint damage = 0;
metalBurst.ChangeMoveDamage(move, firstAttacker, 0, ref damage);
// Assert
await Assert.That(ReceivedFail(hitData)).IsTrue();
}
/// <summary>
/// Bulbapedia: "In battles involving multiple Pokémon, Metal Burst will hit the last opponent that dealt
/// damage to the user" — like <see cref="Counter"/>, the script must implement
/// <see cref="IScriptChangeTargets"/> and redirect the move at the last attacker.
/// </summary>
[Test]
public async Task ChangeTargets_UserHitByOpponents_RedirectsToLastAttacker()
{
// Arrange
var (metalBurst, user, _) = CreateUserWithTracker();
var firstAttacker = CreateOpponent();
var lastAttacker = CreateOpponent();
HitUser(user, firstAttacker, 50);
HitUser(user, lastAttacker, 80);
var choice = Substitute.For<IMoveChoice>();
choice.User.Returns(user);
IReadOnlyList<IPokemon?> targets = [firstAttacker];
// Act & Assert - the script must expose the redirection hook and point the move at the last attacker
var changeTargets = metalBurst as IScriptChangeTargets;
await Assert.That(changeTargets).IsNotNull();
changeTargets!.ChangeTargets(choice, ref targets);
await Assert.That(targets.Count).IsEqualTo(1);
await Assert.That(targets[0]).IsEqualTo(lastAttacker);
}
/// <summary>
/// Bulbapedia: "Metal Burst returns 1.5 times the damage dealt by the foe's last attack." combined with
/// "if the user acts before it is hit by an opponent's damaging move, Metal Burst will fail."
/// Only damage taken during the current turn counts, so the damage tracker must be cleaned up at the end
/// of the turn.
/// </summary>
[Test]
public async Task OnEndTurn_Called_RemovesDamageTrackerFromUser()
{
// Arrange
var (_, user, userVolatile) = CreateUserWithTracker();
var tracker = userVolatile.At(0).Script!;
// Act
((IScriptOnEndTurn)tracker).OnEndTurn(user, Substitute.For<IBattle>());
// Assert
await Assert.That(userVolatile.Get(new StringKey("metal_burst_helper"))).IsNull();
}
}