using PkmnLib.Dynamic.Models; using PkmnLib.Dynamic.Models.Choices; using PkmnLib.Dynamic.ScriptHandling; using PkmnLib.Dynamic.ScriptHandling.Registry; using PkmnLib.Plugin.Gen7.Scripts; using PkmnLib.Plugin.Gen7.Scripts.Moves; using PkmnLib.Plugin.Gen7.Scripts.Pokemon; using PkmnLib.Static; using PkmnLib.Static.Utils; namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; /// /// Tests for the move script and its volatile. /// Gen VII Bulbapedia behavior: Magma Storm deals damage and binds the target. Generation IV: "A bound target /// cannot switch out or flee, and takes damage ... at the end of each turn." Generation V: the trapping /// duration changed to four to five turns. Generations VI and VII: the per-turn damage is increased to 1/8 of /// the target's maximum HP, a Binding Band boosts it to 1/6, and "Ghost-type Pokémon cannot be trapped by /// Magma Storm." /// The trapping and end-of-turn damage themselves are implemented by the /// volatile; the move script determines the duration and damage fraction through the /// custom trigger and applies the volatile to the target. /// public class MagmaStormTests { /// /// Test helper script that modifies the bind duration/damage through the /// custom trigger, the same way the Grip Claw and Binding Band /// item scripts do. /// [Script(ScriptCategory.Pokemon, "test_modify_magma_storm_bind_trigger")] private class ModifyBindTrigger : Script, IScriptCustomTrigger { private readonly int? _duration; private readonly float? _damagePercent; public ModifyBindTrigger(int? duration = null, float? damagePercent = null) { _duration = duration; _damagePercent = damagePercent; } public void CustomTrigger(StringKey eventName, ICustomTriggerArgs args) { if (eventName != CustomTriggers.ModifyBind || args is not CustomTriggers.ModifyBindArgs bindArgs) return; if (_duration.HasValue) bindArgs.Duration = _duration.Value; if (_damagePercent.HasValue) bindArgs.DamagePercent = _damagePercent.Value; } } /// /// Creates a fully mocked test setup for Magma Storm tests. The target gets a real /// as its volatile set, so the applied effect can be inspected. The user's script /// iterator is real but empty unless the test attaches scripts (such as the Grip Claw / Binding Band /// stand-in), so the trigger pass runs. /// private static (MagmaStorm script, IExecutingMove move, IBattlePokemon target, IScriptSet targetVolatile) CreateTestSetup(params Script[] userScripts) { var script = new MagmaStorm(); var move = Substitute.For(); var target = Substitute.For(); target.GetScripts().Returns(_ => new ScriptIterator(new List>())); var targetVolatile = new ScriptSet(target); target.Volatile.Returns(targetVolatile); var user = Substitute.For(); var containers = userScripts.Select(IEnumerable (s) => new ScriptContainer(s)).ToArray(); user.GetScripts().Returns(_ => new ScriptIterator(containers)); move.User.Returns(user); return (script, move, target, targetVolatile); } /// /// Bulbapedia: "Magma Storm deals damage and binds the target". The move script applies the /// volatile, which implements the trap, to the target that was hit. /// [Test] public async Task OnSecondaryEffect_MoveHits_AddsMagmaStormEffectToTargetVolatile() { // Arrange var (script, move, target, targetVolatile) = CreateTestSetup(); // Act script.OnSecondaryEffect(move, target, 0); // Assert await Assert.That(targetVolatile.Get()).IsNotNull(); } /// /// Technical test: hitting a target that is already trapped by Magma Storm does not add a second /// ; the existing volatile is reused. /// [Test] public async Task OnSecondaryEffect_TargetAlreadyTrapped_DoesNotAddSecondEffect() { // Arrange var (script, move, target, targetVolatile) = CreateTestSetup(); // Act script.OnSecondaryEffect(move, target, 0); script.OnSecondaryEffect(move, target, 0); // Assert await Assert.That(targetVolatile.Count).IsEqualTo(1); } // ----- MagmaStormEffect behavior (the volatile the move applies) ----- /// /// Creates a trapped Pokémon substitute with the given maximum HP, together with the /// that traps it. /// private static (MagmaStormEffect effect, IBattlePokemon owner) CreateTrappedPokemon(uint maxHealth, float percentOfMaxHealth = 1f / 8f) { var owner = Substitute.For(); owner.MaxHealth.Returns(maxHealth); owner.Types.Returns(new List { new(10, "fire") }); return (new MagmaStormEffect(owner, 5, percentOfMaxHealth), owner); } /// /// Helper to extract the damage amount from a Pokémon's received Damage calls. /// private static uint? GetDamageAmount(IBattlePokemon pokemon) { var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage"); return call != null ? (uint)call.GetArguments()[0]! : null; } /// /// Runs end-of-turn handling on the effect repeatedly and counts how many turns dealt damage to the target. /// private static int CountEndTurnDamageTicks(MagmaStormEffect effect, IBattlePokemon target, int maxTurns = 10) { var battle = Substitute.For(); for (var i = 0; i < maxTurns; i++) effect.OnEndTurn(target, battle); return target.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "Damage"); } /// /// Bulbapedia (Generations VI and VII): the damage taken by the bound target at the end of each turn is /// increased from 1/16 to 1/8 of its maximum HP. /// [Test, Arguments(96u, 12u), Arguments(100u, 12u), Arguments(120u, 15u), Arguments(8u, 1u)] public async Task OnEndTurn_TrappedPokemon_TakesOneEighthOfMaxHpAsDamage(uint maxHealth, uint expectedDamage) { // Arrange var (effect, owner) = CreateTrappedPokemon(maxHealth); // Act effect.OnEndTurn(owner, Substitute.For()); // Assert await Assert.That(GetDamageAmount(owner)!.Value).IsEqualTo(expectedDamage); } /// /// Bulbapedia (Generation IV): the bound target "takes damage ... at the end of each turn"; the damage is /// dealt once per turn, not more. /// [Test] public async Task OnEndTurn_TrappedPokemon_TakesDamageOncePerTurn() { // Arrange var (effect, owner) = CreateTrappedPokemon(96); // Act effect.OnEndTurn(owner, Substitute.For()); // Assert var damageCalls = owner.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "Damage"); await Assert.That(damageCalls).IsEqualTo(1); } /// /// Bulbapedia (Generation IV): the bound target "takes damage ... at the end of each turn"; this is /// indirect damage, not move damage, so it uses . /// [Test] public async Task OnEndTurn_TrappedPokemon_DamageIsIndirect() { // Arrange var (effect, owner) = CreateTrappedPokemon(100); // Act effect.OnEndTurn(owner, Substitute.For()); // Assert var call = owner.ReceivedCalls().First(c => c.GetMethodInfo().Name == "Damage"); await Assert.That((DamageSource)call.GetArguments()[1]!).IsEqualTo(DamageSource.Misc); } /// /// Bulbapedia (Generation IV): "A bound target cannot switch out". /// [Test] public async Task PreventSelfSwitch_TrappedPokemon_CannotSwitchOut() { // Arrange var (effect, owner) = CreateTrappedPokemon(100); var choice = Substitute.For(); choice.User.Returns(owner); var prevent = false; // Act effect.PreventSelfSwitch(choice, ref prevent); // Assert await Assert.That(prevent).IsTrue(); } /// /// Bulbapedia (Generation IV): "A bound target cannot switch out or flee". /// [Test] public async Task PreventSelfRunAway_TrappedPokemon_CannotFlee() { // Arrange var (effect, owner) = CreateTrappedPokemon(100); var choice = Substitute.For(); choice.User.Returns(owner); var prevent = false; // Act effect.PreventSelfRunAway(choice, ref prevent); // Assert await Assert.That(prevent).IsTrue(); } /// /// Bulbapedia (Generations VI and VII): "Ghost-type Pokémon cannot be trapped by Magma Storm." A trapped /// Ghost-type Pokémon must remain free to switch out. /// [Test] public async Task PreventSelfSwitch_GhostTypeOwner_CanStillSwitchOut() { // Arrange var (effect, owner) = CreateTrappedPokemon(100); owner.Types.Returns(new List { new(8, "ghost") }); var choice = Substitute.For(); choice.User.Returns(owner); var prevent = false; // Act effect.PreventSelfSwitch(choice, ref prevent); // Assert await Assert.That(prevent).IsFalse(); } /// /// Bulbapedia (Generations VI and VII): if the user is holding a Binding Band, the end-of-turn damage of /// Magma Storm is increased to 1/6 of the target's maximum HP. /// The damage change flows through the custom trigger; a script on /// the user that sets the damage to 1/6 results in end-of-turn damage of 1/6 max HP. /// [Test, Arguments(120u, 20u), Arguments(48u, 8u)] public async Task OnSecondaryEffect_DamageSetToOneSixthByTrigger_EndTurnDamageIsOneSixth(uint maxHealth, uint expectedDamage) { // Arrange var (script, move, target, targetVolatile) = CreateTestSetup(new ModifyBindTrigger(damagePercent: 1f / 6f)); target.MaxHealth.Returns(maxHealth); // Act script.OnSecondaryEffect(move, target, 0); var effect = targetVolatile.Get(); await Assert.That(effect).IsNotNull(); effect!.OnEndTurn(target, Substitute.For()); // Assert await Assert.That(GetDamageAmount(target)!.Value).IsEqualTo(expectedDamage); } /// /// Bulbapedia (Generation V onwards): if the user is holding a Grip Claw, the trapping lasts seven turns. /// The duration change flows through the custom trigger; a script /// on the user that sets the duration to 7 results in an effect lasting 7 turns. /// [Test] public async Task OnSecondaryEffect_DurationSetToSevenByTrigger_EffectLastsSevenTurns() { // Arrange var (script, move, target, targetVolatile) = CreateTestSetup(new ModifyBindTrigger(7)); target.MaxHealth.Returns(160u); // Act script.OnSecondaryEffect(move, target, 0); var effect = targetVolatile.Get(); await Assert.That(effect).IsNotNull(); var turnsWithDamage = CountEndTurnDamageTicks(effect!, target); // Assert await Assert.That(turnsWithDamage).IsEqualTo(7); } /// /// Bulbapedia (Generation V onwards): the trapping lasts for four to five turns. After at most five /// end-of-turn ticks the trap must have removed itself from the target. /// [Test] public async Task OnEndTurn_AfterFiveEndOfTurns_TrapHasEnded() { // Arrange var (script, move, target, targetVolatile) = CreateTestSetup(); target.MaxHealth.Returns(80u); script.OnSecondaryEffect(move, target, 0); var effect = targetVolatile.Get()!; var battle = Substitute.For(); // Act - five end-of-turn ticks, the maximum duration of the trap for (var i = 0; i < 5; i++) effect.OnEndTurn(target, battle); // Assert await Assert.That(targetVolatile.Contains("magma_storm")).IsFalse(); } }