diff --git a/PkmnLib.Dynamic/Models/Pokemon.cs b/PkmnLib.Dynamic/Models/Pokemon.cs index 3c3ed98..ddf8544 100644 --- a/PkmnLib.Dynamic/Models/Pokemon.cs +++ b/PkmnLib.Dynamic/Models/Pokemon.cs @@ -248,6 +248,9 @@ public interface IPokemon : IScriptSource, IDeepCloneable [MustUseReturnValue] IItem? RemoveHeldItem(); + /// + /// Whether the held item has been removed for the duration of the battle. + /// bool HasItemBeenRemovedForBattle { get; } /// @@ -451,30 +454,45 @@ public interface IPokemon : IScriptSource, IDeepCloneable /// public interface IPokemonBattleData : IDeepCloneable { + /// + /// Sets the battle data of the Pokémon. + /// + void SetBattle(IBattle battle, byte sideIndex, uint switchInTurn); + + /// + /// Sets the position of the Pokémon on the field. + /// + void SetPosition(byte position); + /// /// The battle the Pokémon is in. /// - IBattle Battle { get; internal set; } + IBattle Battle { get; } /// /// The index of the side of the Pokémon /// - byte SideIndex { get; internal set; } + byte SideIndex { get; } /// /// The index of the position of the Pokémon on the field /// - byte Position { get; internal set; } + byte Position { get; } /// /// A list of opponents the Pokémon has seen this battle. /// IReadOnlyList SeenOpponents { get; } + /// + /// Sets whether the Pokémon is on the battlefield. + /// + void SetOnBattlefield(bool onBattleField); + /// /// Whether the Pokémon is on the battlefield. /// - bool IsOnBattlefield { get; internal set; } + bool IsOnBattlefield { get; } /// /// Adds an opponent to the list of seen opponents. @@ -494,8 +512,11 @@ public interface IPokemonBattleData : IDeepCloneable /// /// The turn the Pokémon switched in. /// - uint SwitchInTurn { get; internal set; } + uint SwitchInTurn { get; } + /// + /// The number of turns the Pokémon has been on the field. + /// uint TurnsOnField { get; } /// @@ -516,7 +537,7 @@ public interface IPokemonBattleData : IDeepCloneable /// /// The last move choice executed by the Pokémon. /// - IMoveChoice? LastMoveChoice { get; internal set; } + IMoveChoice? LastMoveChoice { get; set; } } /// @@ -1315,9 +1336,7 @@ public class PokemonImpl : ScriptSource, IPokemon { if (BattleData is not null) { - BattleData.Battle = battle; - BattleData.SideIndex = sideIndex; - BattleData.SwitchInTurn = battle.CurrentTurnNumber; + BattleData.SetBattle(battle, sideIndex, battle.CurrentTurnNumber); } else { @@ -1340,7 +1359,7 @@ public class PokemonImpl : ScriptSource, IPokemon { if (BattleData is not null) { - BattleData.IsOnBattlefield = onBattleField; + BattleData.SetOnBattlefield(onBattleField); if (!onBattleField) { Volatile.Clear(); @@ -1356,10 +1375,7 @@ public class PokemonImpl : ScriptSource, IPokemon /// public void SetBattleSidePosition(byte position) { - if (BattleData is not null) - { - BattleData.Position = position; - } + BattleData?.SetPosition(position); } /// @@ -1494,6 +1510,20 @@ public class PokemonBattleDataImpl : IPokemonBattleData OriginalForm = originalForm; } + /// + public void SetBattle(IBattle battle, byte sideIndex, uint switchInTurn) + { + Battle = battle; + SideIndex = sideIndex; + SwitchInTurn = switchInTurn; + } + + /// + public void SetPosition(byte position) + { + Position = position; + } + /// public IBattle Battle { get; set; } @@ -1508,6 +1538,12 @@ public class PokemonBattleDataImpl : IPokemonBattleData /// public IReadOnlyList SeenOpponents => _seenOpponents; + /// + public void SetOnBattlefield(bool onBattleField) + { + IsOnBattlefield = onBattleField; + } + /// public bool IsOnBattlefield { get; set; } diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Abilities/AftermathTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Abilities/AftermathTests.cs new file mode 100644 index 0000000..e6f54fe --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Abilities/AftermathTests.cs @@ -0,0 +1,367 @@ +using PkmnLib.Dynamic.Events; +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Plugin.Gen7.Scripts.Abilities; +using PkmnLib.Static.Species; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Abilities; + +/// +/// Tests for the Aftermath ability. +/// +public class AftermathTests +{ + /// + /// Creates a fully mocked test setup for Aftermath tests. + /// + private static (Aftermath aftermath, IExecutingMove move, IPokemon target, IPokemon user, EventHook eventHook, + IBattle battle) CreateFullTestSetup(bool isContact, uint userMaxHealth = 100) + { + var aftermath = new Aftermath(); + var move = Substitute.For(); + var target = Substitute.For(); + var hitData = Substitute.For(); + hitData.IsContact.Returns(isContact); + move.GetHitData(target, 0).Returns(hitData); + + var eventHook = new EventHook(); + var battle = Substitute.For(); + battle.EventHook.Returns(eventHook); + + // Setup empty sides by default (no Damp on field) + var side = Substitute.For(); + side.Pokemon.Returns(new List()); + battle.Sides.Returns(new[] { side }); + + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + + var user = Substitute.For(); + user.IsUsable.Returns(true); + user.MaxHealth.Returns(userMaxHealth); + + // Configure BattleData return value using NSubstitute's callback pattern + // First return null to suppress auto-substitution, then configure actual value + user.BattleData.Returns(battleData); + + move.User.Returns(user); + + return (aftermath, move, target, user, eventHook, battle); + } + + /// + /// Helper to extract the damage amount from a substitute's received Damage calls. + /// + private static uint GetDamageDealt(IPokemon user) + { + var damageCall = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage"); + return damageCall != null ? (uint)damageCall.GetArguments()[0]! : 0; + } + + /// + /// Helper to extract the damage source from a substitute's received Damage calls. + /// + private static DamageSource? GetDamageSource(IPokemon user) + { + var damageCall = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage"); + return damageCall != null ? (DamageSource)damageCall.GetArguments()[1]! : null; + } + + /// + /// Bulbapedia: "When a Pokémon with this Ability faints from damage caused by a move that makes contact, + /// the attacking Pokémon takes damage equal to ¼ of its own maximum HP." + /// This test verifies that contact moves trigger the ability and deal 1/4 max HP damage. + /// + [Test] + public async Task OnFaint_ContactMove_DealsQuarterMaxHpDamage() + { + // Arrange + var (aftermath, move, target, user, _, _) = CreateFullTestSetup(true, 100); + + // Act + aftermath.OnIncomingHit(move, target, 0); + aftermath.OnFaint(target, DamageSource.MoveDamage); + + // Assert - Should deal 25 damage (1/4 of 100) + await Assert.That(GetDamageDealt(user)).IsEqualTo(25u); + } + + /// + /// Bulbapedia: "the attacking Pokémon takes damage equal to ¼ of its own maximum HP." + /// Tests various max HP values to ensure proper integer division. + /// + [Test, Arguments(100u, 25u), Arguments(200u, 50u), Arguments(44u, 11u), Arguments(99u, 24u), Arguments(1u, 0u)] + public async Task OnFaint_ContactMove_DamageCalculation(uint maxHealth, uint expectedDamage) + { + // Arrange + var (aftermath, move, target, user, _, _) = CreateFullTestSetup(true, maxHealth); + + // Act + aftermath.OnIncomingHit(move, target, 0); + aftermath.OnFaint(target, DamageSource.MoveDamage); + + // Assert + await Assert.That(GetDamageDealt(user)).IsEqualTo(expectedDamage); + } + + /// + /// Bulbapedia: Aftermath triggers "from damage caused by a move that makes contact". + /// Non-contact moves should not trigger the ability. + /// + [Test] + public async Task OnFaint_NonContactMove_DoesNotDealDamage() + { + // Arrange + var (aftermath, move, target, user, _, _) = CreateFullTestSetup(false); + + // Act + aftermath.OnIncomingHit(move, target, 0); + aftermath.OnFaint(target, DamageSource.MoveDamage); + + // Assert - Damage should never be called + await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse(); + } + + /// + /// Bulbapedia: Aftermath triggers when the Pokémon "faints from damage". + /// If the faint is not from move damage, the ability should not trigger. + /// + [Test] + public async Task OnFaint_NotFromMoveDamage_DoesNotTrigger() + { + // Arrange + var (aftermath, move, target, user, _, _) = CreateFullTestSetup(true); + + aftermath.OnIncomingHit(move, target, 0); + + // Act - Faint from non-move damage source + aftermath.OnFaint(target, DamageSource.Misc); + + // Assert - Damage should never be called + await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse(); + } + + /// + /// Bulbapedia: "the attacking Pokémon takes damage". + /// If the attacker has already fainted (IsUsable = false), no damage should be dealt. + /// + [Test] + public async Task OnFaint_AttackerNotUsable_DoesNotDealDamage() + { + // Arrange + var aftermath = new Aftermath(); + var move = Substitute.For(); + var target = Substitute.For(); + var hitData = Substitute.For(); + hitData.IsContact.Returns(true); + move.GetHitData(target, 0).Returns(hitData); + + var user = Substitute.For(); + user.IsUsable.Returns(false); // Attacker already fainted + move.User.Returns(user); + + // Act + aftermath.OnIncomingHit(move, target, 0); + aftermath.OnFaint(target, DamageSource.MoveDamage); + + // Assert - Damage should never be called because IsUsable check fails + await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse(); + } + + /// + /// Bulbapedia: "the attacking Pokémon takes damage". + /// If the attacker has no battle data, no damage should be dealt. + /// + [Test] + public async Task OnFaint_AttackerHasNoBattleData_DoesNotDealDamage() + { + // Arrange + var aftermath = new Aftermath(); + var move = Substitute.For(); + var target = Substitute.For(); + var hitData = Substitute.For(); + hitData.IsContact.Returns(true); + move.GetHitData(target, 0).Returns(hitData); + + var user = Substitute.For(); + user.IsUsable.Returns(true); + user.BattleData.Returns((IPokemonBattleData?)null); + move.User.Returns(user); + + // Act + aftermath.OnIncomingHit(move, target, 0); + aftermath.OnFaint(target, DamageSource.MoveDamage); + + // Assert - Damage should never be called because BattleData is null + await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse(); + } + + /// + /// Technical test: Verifies OnFaint handles the case where no attack was received. + /// + [Test] + public void OnFaint_NoLastAttackStored_DoesNotThrow() + { + // Arrange + var aftermath = new Aftermath(); + var target = Substitute.For(); + + // Act & Assert - Should not throw when _lastAttack is null + aftermath.OnFaint(target, DamageSource.MoveDamage); + } + + /// + /// Bulbapedia: Aftermath triggers on "a move that makes contact". + /// When multiple hits occur, only the last hit determines whether Aftermath triggers. + /// This tests that a contact hit after a non-contact hit will trigger. + /// + [Test] + public async Task OnIncomingHit_MultipleHits_LastContactHitTriggers() + { + // Arrange + var aftermath = new Aftermath(); + var target = Substitute.For(); + var battle = Substitute.For(); + var eventHook = new EventHook(); + battle.EventHook.Returns(eventHook); + + // Setup empty sides (no Damp on field) + var side = Substitute.For(); + side.Pokemon.Returns(new List()); + battle.Sides.Returns(new[] { side }); + + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + + // First hit - non-contact + var move1 = Substitute.For(); + var hitData1 = Substitute.For(); + hitData1.IsContact.Returns(false); + move1.GetHitData(target, 0).Returns(hitData1); + + // Second hit - contact (should be stored and trigger) + var move2 = Substitute.For(); + var hitData2 = Substitute.For(); + hitData2.IsContact.Returns(true); + move2.GetHitData(target, 0).Returns(hitData2); + var user = Substitute.For(); + user.IsUsable.Returns(true); + user.BattleData.Returns(battleData); + user.MaxHealth.Returns(100u); + move2.User.Returns(user); + + // Act + aftermath.OnIncomingHit(move1, target, 0); + aftermath.OnIncomingHit(move2, target, 0); + aftermath.OnFaint(target, DamageSource.MoveDamage); + + // Assert - Should deal 25 damage from the last contact hit + await Assert.That(GetDamageDealt(user)).IsEqualTo(25u); + } + + /// + /// Bulbapedia: Aftermath triggers on "a move that makes contact". + /// When the last hit is non-contact, Aftermath should not trigger. + /// + [Test] + public async Task OnIncomingHit_MultipleHits_LastNonContactDoesNotTrigger() + { + // Arrange + var aftermath = new Aftermath(); + var target = Substitute.For(); + + // First hit - contact + var move1 = Substitute.For(); + var hitData1 = Substitute.For(); + hitData1.IsContact.Returns(true); + move1.GetHitData(target, 0).Returns(hitData1); + + // Second hit - non-contact (overwrites, sets to null) + var move2 = Substitute.For(); + var hitData2 = Substitute.For(); + hitData2.IsContact.Returns(false); + move2.GetHitData(target, 0).Returns(hitData2); + + // Act + aftermath.OnIncomingHit(move1, target, 0); + aftermath.OnIncomingHit(move2, target, 0); + aftermath.OnFaint(target, DamageSource.MoveDamage); + + // Assert - Neither move's User should be accessed (_lastAttack is null) + await Assert.That(move1.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "get_User")).IsFalse(); + await Assert.That(move2.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "get_User")).IsFalse(); + } + + /// + /// Bulbapedia: "When a Pokémon with this Ability faints..." + /// Verifies that the AbilityTriggerEvent is fired when Aftermath triggers. + /// + [Test] + public async Task OnFaint_ContactMove_FiresAbilityTriggerEvent() + { + // Arrange + var (aftermath, move, target, user, eventHook, _) = CreateFullTestSetup(true); + AbilityTriggerEvent? capturedEvent = null; + eventHook.Handler += (sender, args) => + { + if (args is AbilityTriggerEvent ate) + capturedEvent = ate; + }; + + // Act + aftermath.OnIncomingHit(move, target, 0); + aftermath.OnFaint(target, DamageSource.MoveDamage); + + // Assert - AbilityTriggerEvent should be fired with the target (Aftermath owner) + await Assert.That(capturedEvent).IsNotNull(); + await Assert.That(capturedEvent!.Pokemon).IsEqualTo(target); + } + + /// + /// Bulbapedia: "the attacking Pokémon takes damage equal to ¼ of its own maximum HP." + /// Verifies that the damage source is DamageSource.Misc (not MoveDamage). + /// + [Test] + public async Task OnFaint_ContactMove_UsesMiscDamageSource() + { + // Arrange + var (aftermath, move, target, user, _, _) = CreateFullTestSetup(true); + + // Act + aftermath.OnIncomingHit(move, target, 0); + aftermath.OnFaint(target, DamageSource.MoveDamage); + + // Assert + await Assert.That(GetDamageSource(user)).IsEqualTo(DamageSource.Misc); + } + + /// + /// Bulbapedia: Aftermath does not activate "if a Pokémon with Damp is on the field". + /// This test verifies that Aftermath is suppressed when any Pokémon on the field has Damp. + /// + [Test] + public async Task OnFaint_DampOnField_DoesNotTrigger() + { + // Arrange + var (aftermath, move, target, user, _, battle) = CreateFullTestSetup(true, 100); + + // Create a Pokemon with Damp ability on the field + var dampPokemon = Substitute.For(); + var dampAbility = Substitute.For(); + dampAbility.Name.Returns(new StringKey("damp")); + dampPokemon.ActiveAbility.Returns(dampAbility); + + // Update battle sides to include the Damp Pokemon + var side = Substitute.For(); + side.Pokemon.Returns(new List { dampPokemon }); + battle.Sides.Returns(new[] { side }); + + // Act + aftermath.OnIncomingHit(move, target, 0); + aftermath.OnFaint(target, DamageSource.MoveDamage); + + // Assert - Damage should never be called because Damp prevents Aftermath + await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Abilities/Aftermath.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Abilities/Aftermath.cs index 07b0c8e..2fdfc50 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Abilities/Aftermath.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Abilities/Aftermath.cs @@ -9,12 +9,14 @@ namespace PkmnLib.Plugin.Gen7.Scripts.Abilities; [Script(ScriptCategory.Ability, "aftermath")] public class Aftermath : Script, IScriptOnIncomingHit, IScriptOnFaint { + private static readonly StringKey DampAbilityName = new("damp"); + private IExecutingMove? _lastAttack; /// public void OnIncomingHit(IExecutingMove move, IPokemon target, byte hit) { - _lastAttack = !move.GetHitData(target, hit).IsContact ? move : null; + _lastAttack = move.GetHitData(target, hit).IsContact ? move : null; } /// @@ -29,8 +31,16 @@ public class Aftermath : Script, IScriptOnIncomingHit, IScriptOnFaint return; if (user.BattleData is null) return; + + // Aftermath does not trigger if a Pokémon with Damp is on the field + var battle = user.BattleData.Battle; + var hasDamp = battle.Sides.SelectMany(side => side.Pokemon).WhereNotNull() + .Any(p => p.ActiveAbility?.Name == DampAbilityName); + if (hasDamp) + return; + EventBatchId eventBatchId = new(); - user.BattleData.Battle.EventHook.Invoke(new AbilityTriggerEvent(pokemon) + battle.EventHook.Invoke(new AbilityTriggerEvent(pokemon) { BatchId = eventBatchId, });