using PkmnLib.Dynamic.Models; using PkmnLib.Dynamic.Models.Choices; using PkmnLib.Dynamic.ScriptHandling; 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 its volatile. /// Gen VII Bulbapedia behavior: Fire Spin inflicts damage and traps the target. Generation V: "it now lasts /// for four to five turns." Generation VI onwards: "The end turn damage of Fire Spin is increased from 1/16 /// to 1/8 of the target's maximum HP." /// The trapping and end-of-turn damage themselves are implemented by the /// volatile; the move script is responsible for applying that volatile to the target. /// public class FireSpinTests { /// /// Creates a fully mocked test setup for Fire Spin tests. The target gets a real /// as its volatile set, so the applied effect can be inspected. /// The battle random is stubbed to return 5, the maximum of Fire Spin's four-to-five turn duration. /// private static (FireSpin script, IExecutingMove move, IBattlePokemon target, IScriptSet targetVolatile) CreateTestSetup() { var script = new FireSpin(); 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 random = Substitute.For(); random.GetInt(Arg.Any(), Arg.Any()).Returns(5); var battle = Substitute.For(); battle.Random.Returns(random); target.Battle.Returns(battle); return (script, move, target, targetVolatile); } /// /// Bulbapedia: Fire Spin traps the target and deals "1/8 of the target's maximum HP" at the end of each /// turn. The move script applies the volatile, which implements the trap, /// to the target that was hit. /// [Test] public async Task OnSecondaryEffect_MoveHits_AddsFireSpinEffectToTargetVolatile() { // 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 Fire Spin 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); } // ----- FireSpinEffect 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 (FireSpinEffect effect, IBattlePokemon owner) CreateTrappedPokemon(uint maxHealth, IBattlePokemon? user = null) { var owner = Substitute.For(); owner.BoostedStats.Returns(new StatisticSet(maxHealth, 1, 1, 1, 1, 1)); owner.MaxHealth.Returns(maxHealth); owner.Types.Returns(new List { new(10, "fire") }); return (new FireSpinEffect(owner, 5, user ?? Substitute.For()), 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; } /// /// Bulbapedia (Generation VI onwards): "The end turn damage of Fire Spin is increased from 1/16 to 1/8 /// of the target's 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: the trap deals "end turn damage"; 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 II onwards): Fire Spin "trapped the opponent, preventing switching." /// [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: Fire Spin traps the target; a trapped wild Pokémon cannot flee (the Generation III-IV /// text lists Run Away and a held Smoke Ball as the only ways a wild Pokémon can still escape). /// [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 (Generation VI onwards): "Ghost-type Pokémon cannot be trapped by Fire Spin." 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 (Generation VI onwards): "If the user is holding a Binding Band, the end turn damage of /// Fire Spin will increase to 1/6 of the target's maximum HP." /// [Test] public async Task OnEndTurn_UserHoldingBindingBand_DamageIsOneSixthOfMaxHp() { // Arrange var (script, move, target, targetVolatile) = CreateTestSetup(); var user = Substitute.For(); user.HasHeldItem("binding_band").Returns(true); move.User.Returns(user); target.BoostedStats.Returns(new StatisticSet(120, 1, 1, 1, 1, 1)); target.MaxHealth.Returns(120u); // Act script.OnSecondaryEffect(move, target, 0); var effect = targetVolatile.Get(); effect!.OnEndTurn(target, Substitute.For()); // Assert - 1/6 of 120 max HP await Assert.That(GetDamageAmount(target)!.Value).IsEqualTo(20u); } /// /// Bulbapedia (Generation V onwards): "it now 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.BoostedStats.Returns(new StatisticSet(80, 1, 1, 1, 1, 1)); 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("fire_spin")).IsFalse(); } }