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,
});