using PkmnLib.Dynamic.Models; using PkmnLib.Dynamic.ScriptHandling; using PkmnLib.Dynamic.ScriptHandling.Registry; using PkmnLib.Plugin.Gen7.Scripts.Moves; using PkmnLib.Plugin.Gen7.Scripts.Pokemon; using PkmnLib.Static; namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; /// /// Tests for the move script and the it applies. /// Gen VII Bulbapedia behavior — non-Ghost user: "The user's Speed stat will drop by one stage and its /// Attack stat and Defense stat will rise by one stage each." Ghost-type user: "The user will lose half of /// its maximum HP (rounded down) and put a curse on the target", and "A cursed Pokémon will lose ¼ of its /// maximum HP at the end of each turn." /// public class CurseTests { private static (Curse script, IExecutingMove move, IPokemon user, IPokemon target, IScriptSet targetVolatile) CreateTestSetup(bool userIsGhost, uint userMaxHealth = 100, uint userCurrentHealth = 100) { var script = new Curse(); var library = LibraryHelpers.LoadLibrary(); library.StaticLibrary.Types.TryGetTypeIdentifier("ghost", out var ghostType); library.StaticLibrary.Types.TryGetTypeIdentifier("normal", out var normalType); var battle = Substitute.For(); battle.Library.Returns(library); var battleData = Substitute.For(); battleData.Battle.Returns(battle); var user = Substitute.For(); user.BattleData.Returns(battleData); user.Types.Returns(new[] { userIsGhost ? ghostType : normalType }); user.MaxHealth.Returns(userMaxHealth); user.CurrentHealth.Returns(userCurrentHealth); var move = Substitute.For(); move.User.Returns(user); var target = Substitute.For(); // Use a real script set so the curse applied to the target can be inspected afterwards. var targetVolatile = new ScriptSet(target); target.Volatile.Returns(targetVolatile); target.GetScripts().Returns(_ => new ScriptIterator(new List>())); return (script, move, user, target, targetVolatile); } /// /// Helper that checks whether a stat boost change was applied to the given Pokémon. /// private static bool ReceivedStatBoost(IPokemon pokemon, Statistic stat, sbyte amount) => pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost" && (Statistic)c.GetArguments()[0]! == stat && (sbyte)c.GetArguments()[1]! == amount); /// /// Helper to extract the damage amount from a substitute's received Damage calls. /// private static uint? GetDamageAmount(IPokemon pokemon) { var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage"); return call != null ? (uint)call.GetArguments()[0]! : null; } /// /// Bulbapedia (non-Ghost user): "The user's Speed stat will drop by one stage and its Attack stat and /// Defense stat will rise by one stage each." /// [Test] public async Task OnSecondaryEffect_NonGhostUser_LowersSpeedAndRaisesAttackAndDefense() { // Arrange var (script, move, user, target, _) = CreateTestSetup(false); // Act script.OnSecondaryEffect(move, target, 0); // Assert await Assert.That(ReceivedStatBoost(user, Statistic.Speed, -1)).IsTrue(); await Assert.That(ReceivedStatBoost(user, Statistic.Attack, 1)).IsTrue(); await Assert.That(ReceivedStatBoost(user, Statistic.Defense, 1)).IsTrue(); } /// /// Bulbapedia: only a Ghost-type user pays HP and curses the target — a non-Ghost user takes no damage /// and does not curse the target. /// [Test] public async Task OnSecondaryEffect_NonGhostUser_DoesNotDamageUserOrCurseTarget() { // Arrange var (script, move, user, target, targetVolatile) = CreateTestSetup(false); // Act script.OnSecondaryEffect(move, target, 0); // Assert await Assert.That(GetDamageAmount(user)).IsNull(); await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsFalse(); } /// /// Bulbapedia (Ghost-type user): "The user will lose half of its maximum HP (rounded down)". At full /// HP that is half of its current HP as well. /// [Test] public async Task OnSecondaryEffect_GhostUserAtFullHp_UserLosesHalfItsMaximumHp() { // Arrange var (script, move, user, target, _) = CreateTestSetup(true, 100, 100); // Act script.OnSecondaryEffect(move, target, 0); // Assert await Assert.That(GetDamageAmount(user)!.Value).IsEqualTo(50u); } /// /// Bulbapedia (Ghost-type user): the user pays HP to "put a curse on the target" — the /// is attached to the target. /// [Test] public async Task OnSecondaryEffect_GhostUser_CursesTarget() { // Arrange var (script, move, _, target, targetVolatile) = CreateTestSetup(true); // Act script.OnSecondaryEffect(move, target, 0); // Assert await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsTrue(); } /// /// Bulbapedia (Ghost-type user): "The user will lose half of its maximum HP (rounded down)" — the cost /// is based on maximum HP, not on the HP the user has left. /// [Test] public async Task OnSecondaryEffect_GhostUserAtHalfHp_UserStillLosesHalfItsMaximumHp() { // Arrange var (script, move, user, target, _) = CreateTestSetup(true, 100, 50); // Act script.OnSecondaryEffect(move, target, 0); // Assert await Assert.That(GetDamageAmount(user)!.Value).IsEqualTo(50u); } /// /// Bulbapedia (Ghost-type user): "If this causes the user's HP to drop to 0, the move will execute /// fully but cause the user to faint." — the curse is still put on the target. /// [Test] public async Task OnSecondaryEffect_GhostUserFaintsFromHpCost_TargetIsStillCursed() { // Arrange var (script, move, user, target, targetVolatile) = CreateTestSetup(true); // The first read of CurrentHealth computes the HP cost; afterwards the user has fainted. user.CurrentHealth.Returns(100u, 0u); // Act script.OnSecondaryEffect(move, target, 0); // Assert await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsTrue(); } /// /// Bulbapedia: "A cursed Pokémon will lose ¼ of its maximum HP at the end of each turn." At full HP /// that is a quarter of its current HP as well. /// [Test] public async Task GhostCurseEffect_OnEndTurnAtFullHp_CursedPokemonLosesQuarterOfMaximumHp() { // Arrange var cursed = Substitute.For(); cursed.MaxHealth.Returns(100u); cursed.CurrentHealth.Returns(100u); var effect = new GhostCurseEffect(cursed); // Act effect.OnEndTurn(Substitute.For(), Substitute.For()); // Assert await Assert.That(GetDamageAmount(cursed)!.Value).IsEqualTo(25u); } /// /// Bulbapedia: "A cursed Pokémon will lose ¼ of its maximum HP at the end of each turn." — the drain /// is based on maximum HP, not on the HP the Pokémon has left. /// [Test] public async Task GhostCurseEffect_OnEndTurnAtLowHp_CursedPokemonStillLosesQuarterOfMaximumHp() { // Arrange var cursed = Substitute.For(); cursed.MaxHealth.Returns(100u); cursed.CurrentHealth.Returns(40u); var effect = new GhostCurseEffect(cursed); // Act effect.OnEndTurn(Substitute.For(), Substitute.For()); // Assert await Assert.That(GetDamageAmount(cursed)!.Value).IsEqualTo(25u); } }