Fixes, tests for Aftermath
All checks were successful
Build / Build (push) Successful in 1m53s

This commit is contained in:
2026-01-09 15:17:54 +01:00
parent 123c3773d2
commit 391edd98f1
3 changed files with 429 additions and 16 deletions

View File

@@ -248,6 +248,9 @@ public interface IPokemon : IScriptSource, IDeepCloneable
[MustUseReturnValue] [MustUseReturnValue]
IItem? RemoveHeldItem(); IItem? RemoveHeldItem();
/// <summary>
/// Whether the held item has been removed for the duration of the battle.
/// </summary>
bool HasItemBeenRemovedForBattle { get; } bool HasItemBeenRemovedForBattle { get; }
/// <summary> /// <summary>
@@ -451,30 +454,45 @@ public interface IPokemon : IScriptSource, IDeepCloneable
/// </summary> /// </summary>
public interface IPokemonBattleData : IDeepCloneable public interface IPokemonBattleData : IDeepCloneable
{ {
/// <summary>
/// Sets the battle data of the Pokémon.
/// </summary>
void SetBattle(IBattle battle, byte sideIndex, uint switchInTurn);
/// <summary>
/// Sets the position of the Pokémon on the field.
/// </summary>
void SetPosition(byte position);
/// <summary> /// <summary>
/// The battle the Pokémon is in. /// The battle the Pokémon is in.
/// </summary> /// </summary>
IBattle Battle { get; internal set; } IBattle Battle { get; }
/// <summary> /// <summary>
/// The index of the side of the Pokémon /// The index of the side of the Pokémon
/// </summary> /// </summary>
byte SideIndex { get; internal set; } byte SideIndex { get; }
/// <summary> /// <summary>
/// The index of the position of the Pokémon on the field /// The index of the position of the Pokémon on the field
/// </summary> /// </summary>
byte Position { get; internal set; } byte Position { get; }
/// <summary> /// <summary>
/// A list of opponents the Pokémon has seen this battle. /// A list of opponents the Pokémon has seen this battle.
/// </summary> /// </summary>
IReadOnlyList<IPokemon> SeenOpponents { get; } IReadOnlyList<IPokemon> SeenOpponents { get; }
/// <summary>
/// Sets whether the Pokémon is on the battlefield.
/// </summary>
void SetOnBattlefield(bool onBattleField);
/// <summary> /// <summary>
/// Whether the Pokémon is on the battlefield. /// Whether the Pokémon is on the battlefield.
/// </summary> /// </summary>
bool IsOnBattlefield { get; internal set; } bool IsOnBattlefield { get; }
/// <summary> /// <summary>
/// Adds an opponent to the list of seen opponents. /// Adds an opponent to the list of seen opponents.
@@ -494,8 +512,11 @@ public interface IPokemonBattleData : IDeepCloneable
/// <summary> /// <summary>
/// The turn the Pokémon switched in. /// The turn the Pokémon switched in.
/// </summary> /// </summary>
uint SwitchInTurn { get; internal set; } uint SwitchInTurn { get; }
/// <summary>
/// The number of turns the Pokémon has been on the field.
/// </summary>
uint TurnsOnField { get; } uint TurnsOnField { get; }
/// <summary> /// <summary>
@@ -516,7 +537,7 @@ public interface IPokemonBattleData : IDeepCloneable
/// <summary> /// <summary>
/// The last move choice executed by the Pokémon. /// The last move choice executed by the Pokémon.
/// </summary> /// </summary>
IMoveChoice? LastMoveChoice { get; internal set; } IMoveChoice? LastMoveChoice { get; set; }
} }
/// <inheritdoc cref="IPokemon"/> /// <inheritdoc cref="IPokemon"/>
@@ -1315,9 +1336,7 @@ public class PokemonImpl : ScriptSource, IPokemon
{ {
if (BattleData is not null) if (BattleData is not null)
{ {
BattleData.Battle = battle; BattleData.SetBattle(battle, sideIndex, battle.CurrentTurnNumber);
BattleData.SideIndex = sideIndex;
BattleData.SwitchInTurn = battle.CurrentTurnNumber;
} }
else else
{ {
@@ -1340,7 +1359,7 @@ public class PokemonImpl : ScriptSource, IPokemon
{ {
if (BattleData is not null) if (BattleData is not null)
{ {
BattleData.IsOnBattlefield = onBattleField; BattleData.SetOnBattlefield(onBattleField);
if (!onBattleField) if (!onBattleField)
{ {
Volatile.Clear(); Volatile.Clear();
@@ -1356,10 +1375,7 @@ public class PokemonImpl : ScriptSource, IPokemon
/// <inheritdoc /> /// <inheritdoc />
public void SetBattleSidePosition(byte position) public void SetBattleSidePosition(byte position)
{ {
if (BattleData is not null) BattleData?.SetPosition(position);
{
BattleData.Position = position;
}
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -1494,6 +1510,20 @@ public class PokemonBattleDataImpl : IPokemonBattleData
OriginalForm = originalForm; OriginalForm = originalForm;
} }
/// <inheritdoc />
public void SetBattle(IBattle battle, byte sideIndex, uint switchInTurn)
{
Battle = battle;
SideIndex = sideIndex;
SwitchInTurn = switchInTurn;
}
/// <inheritdoc />
public void SetPosition(byte position)
{
Position = position;
}
/// <inheritdoc /> /// <inheritdoc />
public IBattle Battle { get; set; } public IBattle Battle { get; set; }
@@ -1508,6 +1538,12 @@ public class PokemonBattleDataImpl : IPokemonBattleData
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<IPokemon> SeenOpponents => _seenOpponents; public IReadOnlyList<IPokemon> SeenOpponents => _seenOpponents;
/// <inheritdoc />
public void SetOnBattlefield(bool onBattleField)
{
IsOnBattlefield = onBattleField;
}
/// <inheritdoc /> /// <inheritdoc />
public bool IsOnBattlefield { get; set; } public bool IsOnBattlefield { get; set; }

View File

@@ -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();
}
}

View File

@@ -9,12 +9,14 @@ namespace PkmnLib.Plugin.Gen7.Scripts.Abilities;
[Script(ScriptCategory.Ability, "aftermath")] [Script(ScriptCategory.Ability, "aftermath")]
public class Aftermath : Script, IScriptOnIncomingHit, IScriptOnFaint public class Aftermath : Script, IScriptOnIncomingHit, IScriptOnFaint
{ {
private static readonly StringKey DampAbilityName = new("damp");
private IExecutingMove? _lastAttack; private IExecutingMove? _lastAttack;
/// <inheritdoc /> /// <inheritdoc />
public void OnIncomingHit(IExecutingMove move, IPokemon target, byte hit) 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 /> /// <inheritdoc />
@@ -29,8 +31,16 @@ public class Aftermath : Script, IScriptOnIncomingHit, IScriptOnFaint
return; return;
if (user.BattleData is null) if (user.BattleData is null)
return; 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(); EventBatchId eventBatchId = new();
user.BattleData.Battle.EventHook.Invoke(new AbilityTriggerEvent(pokemon) battle.EventHook.Invoke(new AbilityTriggerEvent(pokemon)
{ {
BatchId = eventBatchId, BatchId = eventBatchId,
}); });