This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Aftermath ability.
|
||||
/// </summary>
|
||||
public class AftermathTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a fully mocked test setup for Aftermath tests.
|
||||
/// </summary>
|
||||
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<IExecutingMove>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
var hitData = Substitute.For<IHitData>();
|
||||
hitData.IsContact.Returns(isContact);
|
||||
move.GetHitData(target, 0).Returns(hitData);
|
||||
|
||||
var eventHook = new EventHook();
|
||||
var battle = Substitute.For<IBattle>();
|
||||
battle.EventHook.Returns(eventHook);
|
||||
|
||||
// Setup empty sides by default (no Damp on field)
|
||||
var side = Substitute.For<IBattleSide>();
|
||||
side.Pokemon.Returns(new List<IPokemon?>());
|
||||
battle.Sides.Returns(new[] { side });
|
||||
|
||||
var battleData = Substitute.For<IPokemonBattleData>();
|
||||
battleData.Battle.Returns(battle);
|
||||
|
||||
var user = Substitute.For<IPokemon>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to extract the damage amount from a substitute's received Damage calls.
|
||||
/// </summary>
|
||||
private static uint GetDamageDealt(IPokemon user)
|
||||
{
|
||||
var damageCall = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
|
||||
return damageCall != null ? (uint)damageCall.GetArguments()[0]! : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to extract the damage source from a substitute's received Damage calls.
|
||||
/// </summary>
|
||||
private static DamageSource? GetDamageSource(IPokemon user)
|
||||
{
|
||||
var damageCall = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
|
||||
return damageCall != null ? (DamageSource)damageCall.GetArguments()[1]! : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "the attacking Pokémon takes damage equal to ¼ of its own maximum HP."
|
||||
/// Tests various max HP values to ensure proper integer division.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: Aftermath triggers "from damage caused by a move that makes contact".
|
||||
/// Non-contact moves should not trigger the ability.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: Aftermath triggers when the Pokémon "faints from damage".
|
||||
/// If the faint is not from move damage, the ability should not trigger.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "the attacking Pokémon takes damage".
|
||||
/// If the attacker has already fainted (IsUsable = false), no damage should be dealt.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnFaint_AttackerNotUsable_DoesNotDealDamage()
|
||||
{
|
||||
// Arrange
|
||||
var aftermath = new Aftermath();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
var hitData = Substitute.For<IHitData>();
|
||||
hitData.IsContact.Returns(true);
|
||||
move.GetHitData(target, 0).Returns(hitData);
|
||||
|
||||
var user = Substitute.For<IPokemon>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "the attacking Pokémon takes damage".
|
||||
/// If the attacker has no battle data, no damage should be dealt.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnFaint_AttackerHasNoBattleData_DoesNotDealDamage()
|
||||
{
|
||||
// Arrange
|
||||
var aftermath = new Aftermath();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
var hitData = Substitute.For<IHitData>();
|
||||
hitData.IsContact.Returns(true);
|
||||
move.GetHitData(target, 0).Returns(hitData);
|
||||
|
||||
var user = Substitute.For<IPokemon>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: Verifies OnFaint handles the case where no attack was received.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void OnFaint_NoLastAttackStored_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var aftermath = new Aftermath();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
|
||||
// Act & Assert - Should not throw when _lastAttack is null
|
||||
aftermath.OnFaint(target, DamageSource.MoveDamage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnIncomingHit_MultipleHits_LastContactHitTriggers()
|
||||
{
|
||||
// Arrange
|
||||
var aftermath = new Aftermath();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
var battle = Substitute.For<IBattle>();
|
||||
var eventHook = new EventHook();
|
||||
battle.EventHook.Returns(eventHook);
|
||||
|
||||
// Setup empty sides (no Damp on field)
|
||||
var side = Substitute.For<IBattleSide>();
|
||||
side.Pokemon.Returns(new List<IPokemon?>());
|
||||
battle.Sides.Returns(new[] { side });
|
||||
|
||||
var battleData = Substitute.For<IPokemonBattleData>();
|
||||
battleData.Battle.Returns(battle);
|
||||
|
||||
// First hit - non-contact
|
||||
var move1 = Substitute.For<IExecutingMove>();
|
||||
var hitData1 = Substitute.For<IHitData>();
|
||||
hitData1.IsContact.Returns(false);
|
||||
move1.GetHitData(target, 0).Returns(hitData1);
|
||||
|
||||
// Second hit - contact (should be stored and trigger)
|
||||
var move2 = Substitute.For<IExecutingMove>();
|
||||
var hitData2 = Substitute.For<IHitData>();
|
||||
hitData2.IsContact.Returns(true);
|
||||
move2.GetHitData(target, 0).Returns(hitData2);
|
||||
var user = Substitute.For<IPokemon>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: Aftermath triggers on "a move that makes contact".
|
||||
/// When the last hit is non-contact, Aftermath should not trigger.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnIncomingHit_MultipleHits_LastNonContactDoesNotTrigger()
|
||||
{
|
||||
// Arrange
|
||||
var aftermath = new Aftermath();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
|
||||
// First hit - contact
|
||||
var move1 = Substitute.For<IExecutingMove>();
|
||||
var hitData1 = Substitute.For<IHitData>();
|
||||
hitData1.IsContact.Returns(true);
|
||||
move1.GetHitData(target, 0).Returns(hitData1);
|
||||
|
||||
// Second hit - non-contact (overwrites, sets to null)
|
||||
var move2 = Substitute.For<IExecutingMove>();
|
||||
var hitData2 = Substitute.For<IHitData>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "When a Pokémon with this Ability faints..."
|
||||
/// Verifies that the AbilityTriggerEvent is fired when Aftermath triggers.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "the attacking Pokémon takes damage equal to ¼ of its own maximum HP."
|
||||
/// Verifies that the damage source is DamageSource.Misc (not MoveDamage).
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<IPokemon>();
|
||||
var dampAbility = Substitute.For<IAbility>();
|
||||
dampAbility.Name.Returns(new StringKey("damp"));
|
||||
dampPokemon.ActiveAbility.Returns(dampAbility);
|
||||
|
||||
// Update battle sides to include the Damp Pokemon
|
||||
var side = Substitute.For<IBattleSide>();
|
||||
side.Pokemon.Returns(new List<IPokemon?> { 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user