using PkmnLib.Dynamic.Models; using PkmnLib.Dynamic.ScriptHandling; using PkmnLib.Plugin.Gen7.Scripts.Moves; using PkmnLib.Static; namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; /// /// Tests for the move script. /// Gen VII Bulbapedia behavior: if the move misses, the user takes crash damage. Generation V onwards: /// "The crash damage is now (always) equal to half of the user's max HP, rounded down." /// Crash damage also applies when the move is protected against (Generation II onwards: "The user will take /// crash damage if Hi Jump Kick is protected against by a move such as Protect") and due to a target's type /// immunity (Generation IV onwards: "The user can now crash due to a target's type immunity"). /// The script implements and applies crash damage when none of the move's /// hits executed with damage. /// public class HighJumpKickTests { /// /// Creates a fully mocked test setup for High Jump Kick tests, with the given user max HP /// () and hit data. The engine calculates a hit's damage before the /// accuracy and block checks, so a missed or blocked hit still carries a damage value but has /// set to false. /// private static (HighJumpKick script, IExecutingMove move, IBattlePokemon target, IBattlePokemon user) CreateTestSetup(uint maxHp, params (bool hasExecuted, uint damage)[] hits) { var script = new HighJumpKick(); var move = Substitute.For(); var target = Substitute.For(); var hitData = hits.Select(h => { var hit = Substitute.For(); hit.HasExecuted.Returns(h.hasExecuted); hit.Damage.Returns(h.damage); return hit; }).ToArray(); move.Hits.Returns(hitData); var user = Substitute.For(); user.BoostedStats.Returns(new StatisticSet(maxHp, 0, 0, 0, 0, 0)); move.User.Returns(user); return (script, move, target, user); } /// /// Helper to extract the damage amount from the user's received Damage calls. /// private static uint? GetDamageAmount(IBattlePokemon user) { var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage"); return call != null ? (uint)call.GetArguments()[0]! : null; } /// /// Helper to extract the damage source from the user's received Damage calls. /// private static DamageSource? GetDamageSource(IBattlePokemon user) { var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage"); return call != null ? (DamageSource)call.GetArguments()[1]! : null; } /// /// Bulbapedia (Generation V onwards): "The crash damage is now (always) equal to half of the user's max /// HP, rounded down." /// When the move misses, the hit never executes; after the hit loop the engine invokes /// and the user takes half of its own max HP as crash damage. /// [Test] public async Task OnAfterHits_MoveMissed_UserTakesHalfMaxHpCrashDamage() { // Arrange - the missed hit has its would-be damage calculated but never executed. var (script, move, target, user) = CreateTestSetup(200, (false, 400)); // Act script.OnAfterHits(move, target); // Assert await Assert.That(GetDamageAmount(user)!.Value).IsEqualTo(100u); } /// /// Bulbapedia (Generation V onwards): "The crash damage is now (always) equal to half of the user's max /// HP, rounded down." /// Odd max HP values are rounded down when halved. /// [Test, Arguments(200u, 100u), Arguments(301u, 150u), Arguments(75u, 37u)] public async Task OnAfterHits_MoveMissed_CrashDamageIsHalfMaxHpRoundedDown(uint maxHp, uint expectedCrash) { // Arrange var (script, move, target, user) = CreateTestSetup(maxHp, (false, 400)); // Act script.OnAfterHits(move, target); // Assert await Assert.That(GetDamageAmount(user)!.Value).IsEqualTo(expectedCrash); } /// /// With a max HP of 1 the crash damage rounds down to 0, and the script does not invoke a zero-damage /// call on the user at all. /// [Test] public async Task OnAfterHits_MaxHpOne_NoDamageCallOnUser() { // Arrange var (script, move, target, user) = CreateTestSetup(1, (false, 400)); // Act script.OnAfterHits(move, target); // Assert await Assert.That(GetDamageAmount(user)).IsNull(); } /// /// Bulbapedia (Generation V onwards): "The crash damage is now (always) equal to half of the user's max /// HP, rounded down." /// Since Generation V the crash damage no longer depends on the damage the move would have dealt (that /// was the Generation III/IV mechanic), so a low would-be damage still results in half the user's max HP. /// [Test] public async Task OnAfterHits_LowWouldBeDamage_CrashDamageStillHalfMaxHp() { // Arrange var (script, move, target, user) = CreateTestSetup(200, (false, 10)); // Act script.OnAfterHits(move, target); // Assert await Assert.That(GetDamageAmount(user)!.Value).IsEqualTo(100u); } /// /// When the move hits and deals damage, the user takes no crash damage. /// [Test] public async Task OnAfterHits_MoveHitWithDamage_NoCrashDamage() { // Arrange var (script, move, target, user) = CreateTestSetup(200, (true, 400)); // Act script.OnAfterHits(move, target); // Assert await Assert.That(GetDamageAmount(user)).IsNull(); } /// /// Bulbapedia (Generation V onwards): "The crash damage is now (always) equal to half of the user's max /// HP, rounded down." /// Crash damage is indirect damage, not move damage, so it uses . /// [Test] public async Task OnAfterHits_MoveMissed_UsesMiscDamageSource() { // Arrange var (script, move, target, user) = CreateTestSetup(200, (false, 400)); // Act script.OnAfterHits(move, target); // Assert await Assert.That(GetDamageSource(user)!.Value).IsEqualTo(DamageSource.Misc); } /// /// Bulbapedia (Generation II onwards): "The user will take crash damage if Hi Jump Kick is protected /// against by a move such as Protect". /// When the hit is blocked by protection, the engine (MoveTurnExecutor) breaks out of the hit loop /// without executing the hit, then invokes ; the blocked hit's would-be /// damage was already calculated but the hit never executed, so crash damage applies. /// [Test] public async Task OnAfterHits_MoveBlockedByProtect_UserTakesCrashDamage() { // Arrange - a blocked hit carries its calculated would-be damage but never executed. var (script, move, target, user) = CreateTestSetup(200, (false, 400)); // Act script.OnAfterHits(move, target); // Assert var damage = GetDamageAmount(user); await Assert.That(damage).IsNotNull(); await Assert.That(damage!.Value).IsEqualTo(100u); } /// /// Bulbapedia (Generation IV onwards): "The user can now crash due to a target's type immunity", with the /// Generation V onwards amount: "The crash damage is now (always) equal to half of the user's max HP, /// rounded down." /// Against a Ghost-type target the hit deals no damage (effectiveness 0), so the engine never marks the /// hit as executed; still runs and applies crash damage. /// [Test] public async Task OnAfterHits_TargetImmuneToMove_UserTakesCrashDamage() { // Arrange - a Ghost-type target: the hit deals no damage and is never marked as executed. var (script, move, target, user) = CreateTestSetup(200, (false, 0)); // Act script.OnAfterHits(move, target); // Assert var damage = GetDamageAmount(user); await Assert.That(damage).IsNotNull(); await Assert.That(damage!.Value).IsEqualTo(100u); } /// /// A miss caused by the target being in the semi-invulnerable turn of a move such as Fly or Dig is still /// a miss, so per Bulbapedia (Generation V onwards) the user takes crash damage "equal to half of the /// user's max HP, rounded down." For a semi-invulnerable target /// () the engine skips the hit loop entirely and invokes /// before returning, so the hit data is left untouched (not executed, /// no damage calculated) and the script applies crash damage. /// [Test] public async Task OnAfterHits_MoveAvoidedBySemiInvulnerableTarget_UserTakesCrashDamage() { // Arrange - the hit loop never ran, so the hit was never executed and carries no damage. var (script, move, target, user) = CreateTestSetup(200, (false, 0)); // Act script.OnAfterHits(move, target); // Assert var damage = GetDamageAmount(user); await Assert.That(damage).IsNotNull(); await Assert.That(damage!.Value).IsEqualTo(100u); } }