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; /// /// Tests for the script, which implements Metal Burst. /// Behavior is verified against the Bulbapedia page for Metal Burst (Generation VII). /// public class MetalBurstTests { /// /// Creates a Metal Burst user whose damage-tracking volatile has been installed through /// . The user's is a real /// so the internal tracker script can actually be stored and retrieved. /// private static (MetalBurst metalBurst, IPokemon user, IScriptSet userVolatile) CreateUserWithTracker() { var metalBurst = new MetalBurst(); var user = Substitute.For(); 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>())); var choice = Substitute.For(); choice.User.Returns(user); metalBurst.OnBeforeTurnStart(choice); return (metalBurst, user, userVolatile); } /// /// Creates an opposing Pokémon with a real (empty) as its volatile collection. /// private static IPokemon CreateOpponent() { var opponent = Substitute.For(); opponent.Volatile.Returns(new ScriptSet(opponent)); return opponent; } /// /// Simulates the user being hit by an opponent's move; the damage tracker installed on the user observes /// this through its hook. /// private static void HitUser(IPokemon user, IPokemon attacker, uint damage, MoveCategory category = MoveCategory.Physical, byte hit = 0) { var attackMove = Substitute.For(); attackMove.User.Returns(attacker); var moveData = Substitute.For(); moveData.Category.Returns(category); attackMove.UseMove.Returns(moveData); var hitData = Substitute.For(); hitData.Damage.Returns(damage); attackMove.GetHitData(user, hit).Returns(hitData); var tracker = user.Volatile.Select(c => c.Script).OfType().Single(); tracker.OnIncomingHit(attackMove, user, hit); } /// /// Creates the executing Metal Burst move used by against . /// private static (IExecutingMove move, IHitData hitData) CreateMetalBurstMove(IPokemon user, IPokemon target) { var move = Substitute.For(); move.User.Returns(user); var hitData = Substitute.For(); move.GetHitData(target, 0).Returns(hitData); return (move, hitData); } /// /// Helper that checks whether was called on the hit data. /// private static bool ReceivedFail(IHitData hitData) => hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail"); /// /// 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. /// [Test] public async Task OnBeforeTurnStart_Called_AddsDamageTrackerToUserVolatile() { // Arrange & Act var (_, _, userVolatile) = CreateUserWithTracker(); // Assert await Assert.That(userVolatile.Count).IsEqualTo(1); } /// /// 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). /// [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); } /// /// 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. /// [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(); } /// /// 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. /// [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); } /// /// 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. /// [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(); } /// /// 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." /// [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); } /// /// 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. /// [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); } /// /// 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. /// [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(); } /// /// Bulbapedia: "In battles involving multiple Pokémon, Metal Burst will hit the last opponent that dealt /// damage to the user" — like , the script must implement /// and redirect the move at the last attacker. /// [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(); choice.User.Returns(user); IReadOnlyList 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); } /// /// 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. /// [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()); // Assert await Assert.That(userVolatile.Get(new StringKey("metal_burst_helper"))).IsNull(); } }