From 2b0484a7cb4b006ee70818deada32421b423a5b5 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sun, 18 May 2025 18:41:00 -0400 Subject: [PATCH] Fixed info resetting and added backup `leaveField` check is this bad? yes does it work? maybe am i going insane? 101% --- src/data/abilities/ability.ts | 10 +- src/data/mixins/force-switch.ts | 46 +++++--- src/data/moves/move.ts | 8 +- src/field/pokemon.ts | 5 +- src/modifier/modifier.ts | 4 + src/phases/faint-phase.ts | 3 +- src/phases/move-effect-phase.ts | 7 +- src/phases/move-phase.ts | 5 +- src/phases/switch-phase.ts | 7 +- src/phases/switch-summon-phase.ts | 33 +++--- test/abilities/wimp_out.test.ts | 57 ++++++---- test/items/reviver_seed.test.ts | 182 +++++++++++++++++------------- test/moves/dragon_tail.test.ts | 65 +++++++---- 13 files changed, 252 insertions(+), 180 deletions(-) diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 47337b522f2..c764e9a49a0 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -1250,7 +1250,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr { /** * Determine if the move type change attribute can be applied - * + * * Can be applied if: * - The ability's condition is met, e.g. pixilate only boosts normal moves, * - The move is not forbidden from having its type changed by an ability, e.g. {@linkcode Moves.MULTI_ATTACK} @@ -1266,7 +1266,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr { */ override canApplyPreAttack(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _defender: Pokemon | null, move: Move, _args: [NumberHolder?, NumberHolder?, ...any]): boolean { return (!this.condition || this.condition(pokemon, _defender, move)) && - !noAbilityTypeOverrideMoves.has(move.id) && + !noAbilityTypeOverrideMoves.has(move.id) && (!pokemon.isTerastallized || (move.id !== Moves.TERA_BLAST && (move.id !== Moves.TERA_STARSTORM || pokemon.getTeraType() !== PokemonType.STELLAR || !pokemon.hasSpecies(Species.TERAPAGOS)))); @@ -6946,10 +6946,12 @@ export function initAbilities() { .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1), new Ability(Abilities.WIMP_OUT, 7) .attr(PostDamageForceSwitchAbAttr) - .condition(getSheerForceHitDisableAbCondition()), + .condition(getSheerForceHitDisableAbCondition()) + .bypassFaint(), // allows Wimp Out to activate with Reviver Seed new Ability(Abilities.EMERGENCY_EXIT, 7) .attr(PostDamageForceSwitchAbAttr) - .condition(getSheerForceHitDisableAbCondition()), + .condition(getSheerForceHitDisableAbCondition()) + .bypassFaint(), new Ability(Abilities.WATER_COMPACTION, 7) .attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2), new Ability(Abilities.MERCILESS, 7) diff --git a/src/data/mixins/force-switch.ts b/src/data/mixins/force-switch.ts index 4a8b4f25b4b..21f3e9b3ba3 100644 --- a/src/data/mixins/force-switch.ts +++ b/src/data/mixins/force-switch.ts @@ -35,6 +35,12 @@ export function ForceSwitch(Base: TBase) { protected canSwitchOut(switchOutTarget: Pokemon): boolean { const isPlayer = switchOutTarget instanceof PlayerPokemon; + if (switchOutTarget.isFainted()) { + // Fainted Pokemon cannot be switched out by any means. + // This is already checked in `MoveEffectAttr.canApply`, but better safe than sorry + return false; + } + // If we aren't switching ourself out, ensure the target in question can actually be switched out by us if (!this.selfSwitch && !this.performForceSwitchChecks(switchOutTarget)) { return false; @@ -86,15 +92,10 @@ export function ForceSwitch(Base: TBase) { /** * Wrapper function to handle the actual "switching out" of Pokemon. - * @param switchOutTarget - The {@linkcode Pokemon} (player or enemy) to be switched switch out. + * @param switchOutTarget - The {@linkcode Pokemon} (player or enemy) to be switched out. */ protected doSwitch(switchOutTarget: Pokemon): void { - if (switchOutTarget instanceof PlayerPokemon) { - this.trySwitchPlayerPokemon(switchOutTarget); - return; - } - - if (!(switchOutTarget instanceof EnemyPokemon)) { + if (!(switchOutTarget instanceof PlayerPokemon) && !(switchOutTarget instanceof EnemyPokemon)) { console.warn( "Switched out target (index %i) neither player nor enemy Pokemon!", switchOutTarget.getFieldIndex(), @@ -102,17 +103,31 @@ export function ForceSwitch(Base: TBase) { return; } - if (globalScene.currentBattle.battleType !== BattleType.WILD) { - this.trySwitchTrainerPokemon(switchOutTarget); - return; + switch (true) { + case switchOutTarget instanceof PlayerPokemon: + this.trySwitchPlayerPokemon(switchOutTarget); + break; + case globalScene.currentBattle.battleType !== BattleType.WILD: + this.trySwitchTrainerPokemon(switchOutTarget); + break; + default: + this.tryFleeWildPokemon(switchOutTarget); } - this.tryFleeWildPokemon(switchOutTarget); + // Hide the info container as soon as the switch out occurs. + // Effects are kept to ensure correct Shell Bell interactions. + // TODO: Should we hide the info container for wild fleeing? + // Currently keeping it same as prior logic for consistency + if (switchOutTarget instanceof EnemyPokemon && globalScene.currentBattle.battleType === BattleType.WILD) { + switchOutTarget.hideInfo(); + } } - // NB: `prependToPhase` is used here to ensure that the switch happens before the move ends - // and `arena.ignoreAbilities` is reset. - // This ensures ability ignore effects will persist for the duration of the switch (for hazards). + /* + NB: `prependToPhase` is used here to ensure that the switch happens before the move ends + and `arena.ignoreAbilities` is reset. + This ensures ability ignore effects will persist for the duration of the switch (for hazards, etc). + */ private trySwitchPlayerPokemon(switchOutTarget: PlayerPokemon): void { // If not forced to switch, add a SwitchPhase to allow picking the next switched in Pokemon. @@ -156,7 +171,6 @@ export function ForceSwitch(Base: TBase) { private tryFleeWildPokemon(switchOutTarget: EnemyPokemon): void { // flee wild pokemon, redirecting moves to an ally in doubles as applicable. - switchOutTarget.leaveField(false); globalScene.queueMessage( i18next.t("moveTriggers:fled", { pokemonName: getPokemonNameWithAffix(switchOutTarget) }), null, @@ -169,7 +183,7 @@ export function ForceSwitch(Base: TBase) { globalScene.redirectPokemonMoves(switchOutTarget, allyPokemon); } - // End battle if no enemies are active and enemy wasn't already KO'd (kos do ) + // End battle if no enemies are active and enemy wasn't already KO'd if (!allyPokemon?.isActive(true) && !switchOutTarget.isFainted()) { globalScene.clearEnemyHeldItemModifiers(); diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index ddd0ca3e9d3..763f7987887 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1239,10 +1239,12 @@ export class MoveEffectAttr extends MoveAttr { * @param user {@linkcode Pokemon} using the move * @param target {@linkcode Pokemon} target of the move * @param move {@linkcode Move} with this attribute - * @param args Set of unique arguments needed by this attribute - * @returns true if basic application of the ability attribute should be possible + * @param args - Any unique arguments needed by this attribute + * @returns `true` if basic application of the ability attribute should be possible. + * By default, checks that the target is not fainted and (for non self-targeting moves) not protected by an effect. */ canApply(user: Pokemon, target: Pokemon, move: Move, args?: any[]) { + // TODO: why do we check frenzy tag here? return !! (this.selfTarget ? user.hp && !user.getTag(BattlerTagType.FRENZY) : target.hp) && (this.selfTarget || !target.getTag(BattlerTagType.PROTECTED) || move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target })); @@ -6239,7 +6241,7 @@ export class ForceSwitchOutAttr extends ForceSwitch(MoveEffectAttr) { } getCondition(): MoveConditionFunc { - // Damaging switch moves should not "fail" _per se_ upon a failed switch - + // Damaging switch moves should not "fail" _per se_ upon an unsuccessful switch - // they still succeed and deal damage (but just without actually switching out). return (user, target, move) => (move.category !== MoveCategory.STATUS || this.getSwitchOutCondition()(user, target, move)); } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index d341b15540c..ed223926f92 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -6326,9 +6326,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * @param hideInfo - Whether to play the animation to hide the Pokemon's info container; default `true`. * @param destroy - Whether to destroy this Pokemon once it leaves the field; default `false` * @remarks - * This **SHOULD NOT** be called when a `SummonPhase` or `SwitchSummonPhase` is already being added, - * which can lead to premature resetting of {@linkcode turnData} and {@linkcode summonData}. + * This **SHOULD NOT** be called with `clearEffects=true` when a `SummonPhase` or `SwitchSummonPhase` is already being added, + * both of which do so already and can lead to premature resetting of {@linkcode turnData} and {@linkcode summonData}. */ + // TODO: Review where this is being called and where it is necessary to call it leaveField(clearEffects = true, hideInfo = true, destroy = false) { console.log(`leaveField called on Pokemon ${this.name}`) this.resetSprite(); diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index d57d153d7cc..0fa78e928ab 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -1788,6 +1788,10 @@ export class HitHealModifier extends PokemonHeldItemModifier { } const totalDmgDealt = pokemon.turnData.lastMoveDamageDealt.reduce((r, d) => r + d, 0); + if (totalDmgDealt === 0) { + return false; + } + globalScene.unshiftPhase( new PokemonHealPhase( pokemon.getBattlerIndex(), diff --git a/src/phases/faint-phase.ts b/src/phases/faint-phase.ts index 1aa24d59fa0..b03b7ec9511 100644 --- a/src/phases/faint-phase.ts +++ b/src/phases/faint-phase.ts @@ -61,8 +61,6 @@ export class FaintPhase extends PokemonPhase { faintPokemon.getTag(BattlerTagType.GRUDGE)?.lapse(faintPokemon, BattlerTagLapseType.CUSTOM, this.source); } - faintPokemon.resetSummonData(); - if (!this.preventInstantRevive) { const instantReviveModifier = globalScene.applyModifier( PokemonInstantReviveModifier, @@ -71,6 +69,7 @@ export class FaintPhase extends PokemonPhase { ) as PokemonInstantReviveModifier; if (instantReviveModifier) { + faintPokemon.resetSummonData(); faintPokemon.loseHeldItem(instantReviveModifier); globalScene.updateModifiers(this.player); return this.end(); diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 3134f57b3c8..80bb47ae1b1 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -3,7 +3,6 @@ import { globalScene } from "#app/global-scene"; import { AddSecondStrikeAbAttr, AlwaysHitAbAttr, - applyAbAttrs, applyPostAttackAbAttrs, applyPostDamageAbAttrs, applyPostDefendAbAttrs, @@ -79,7 +78,6 @@ import type Move from "#app/data/moves/move"; import { isFieldTargeted } from "#app/data/moves/move-utils"; import { FaintPhase } from "./faint-phase"; import { DamageAchv } from "#app/system/achv"; -import { userInfo } from "node:os"; type HitCheckEntry = [HitCheckResult, TypeDamageMultiplier]; @@ -767,7 +765,6 @@ export class MoveEffectPhase extends PokemonPhase { * - Triggering form changes and emergency exit / wimp out if this is the last hit * * @param target - the {@linkcode Pokemon} hit by this phase's move. - * @param targetIndex - The index of the target (used to update damage dealt amounts) * @param effectiveness - The effectiveness of the move (as previously evaluated in {@linkcode hitCheck}) * @param firstTarget - Whether this is the first target successfully struck by the move */ @@ -813,7 +810,7 @@ export class MoveEffectPhase extends PokemonPhase { */ applyMoveAttrs(StatChangeBeforeDmgCalcAttr, user, target, this.move); - const { result: result, damage: dmg } = target.getAttackDamage({ + const { result, damage: dmg } = target.getAttackDamage({ source: user, move: this.move, ignoreAbility: false, @@ -852,7 +849,7 @@ export class MoveEffectPhase extends PokemonPhase { ? 0 : target.damageAndUpdate(dmg, { result: result as DamageResult, - ignoreFaintPhase: true, + ignoreFaintPhase: true, // ignore faint phase so we can handle it ourselves ignoreSegments: isOneHitKo, isCritical, }); diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 2b7f1254b39..3bc50326ed8 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -43,7 +43,7 @@ import { CommonAnimPhase } from "#app/phases/common-anim-phase"; import { MoveChargePhase } from "#app/phases/move-charge-phase"; import { MoveEffectPhase } from "#app/phases/move-effect-phase"; import { MoveEndPhase } from "#app/phases/move-end-phase"; -import { getEnumValues, NumberHolder } from "#app/utils/common"; +import { NumberHolder } from "#app/utils/common"; import { Abilities } from "#enums/abilities"; import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type"; @@ -160,7 +160,8 @@ export class MovePhase extends BattlePhase { } this.pokemon.turnData.acted = true; - this.pokemon.turnData.lastMoveDamageDealt = Array(Math.max(...getEnumValues(BattlerIndex))).fill(0); + // TODO: Increase this if triple battles are added + this.pokemon.turnData.lastMoveDamageDealt = Array(4).fill(0); // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats) if (this.followUp) { diff --git a/src/phases/switch-phase.ts b/src/phases/switch-phase.ts index 7bc35e0d61f..ee8a5f6aa44 100644 --- a/src/phases/switch-phase.ts +++ b/src/phases/switch-phase.ts @@ -36,8 +36,8 @@ export class SwitchPhase extends BattlePhase { start() { super.start(); - // Failsafe: skip modal switches if impossible (no eligible party members in reserve). - if (this.isModal && globalScene.getBackupPartyMemberIndices(true).length === 0) { + // Skip modal switch if impossible (no remaining party members that aren't in battle) + if (this.isModal && !globalScene.getPlayerParty().filter(p => p.isAllowedInBattle() && !p.isActive(true)).length) { return super.end(); } @@ -53,10 +53,9 @@ export class SwitchPhase extends BattlePhase { } // Check if there is any space still on field. - // TODO: Do we need this? if ( this.isModal && - globalScene.getPlayerField().filter(p => p.isActive(true)).length > globalScene.currentBattle.getBattlerCount() + globalScene.getPlayerField().filter(p => p.isActive(true)).length >= globalScene.currentBattle.getBattlerCount() ) { return super.end(); } diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index 0a0038d7ff0..edf1e127f3e 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -45,6 +45,15 @@ export class SwitchSummonPhase extends SummonPhase { } preSummon(): void { + const switchOutPokemon = this.getPokemon(); + + // if the target is still on-field, remove it and/or hide its info container. + // Effects are kept to be transferred to the new Pokemon if applicable + // TODO: Make moves that switch out pokemon defer to this phase + if (switchOutPokemon.isOnField()) { + switchOutPokemon.leaveField(false, switchOutPokemon.getBattleInfo()?.visible); + } + if (!this.player) { if (this.slotIndex === -1) { //@ts-ignore @@ -71,13 +80,12 @@ export class SwitchSummonPhase extends SummonPhase { return; } - const lastPokemon = this.getPokemon(); (this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach(enemyPokemon => - enemyPokemon.removeTagsBySourceId(lastPokemon.id), + enemyPokemon.removeTagsBySourceId(switchOutPokemon.id), ); if (this.switchType === SwitchType.SWITCH || this.switchType === SwitchType.INITIAL_SWITCH) { - const substitute = lastPokemon.getTag(SubstituteTag); + const substitute = switchOutPokemon.getTag(SubstituteTag); if (substitute) { globalScene.tweens.add({ targets: substitute.sprite, @@ -92,25 +100,26 @@ export class SwitchSummonPhase extends SummonPhase { globalScene.ui.showText( this.player ? i18next.t("battle:playerComeBack", { - pokemonName: getPokemonNameWithAffix(lastPokemon), + pokemonName: getPokemonNameWithAffix(switchOutPokemon), }) : i18next.t("battle:trainerComeBack", { trainerName: globalScene.currentBattle.trainer?.getName( !(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER, ), - pokemonName: lastPokemon.getNameToRender(), + pokemonName: switchOutPokemon.getNameToRender(), }), ); globalScene.playSound("se/pb_rel"); - lastPokemon.hideInfo(); - lastPokemon.tint(getPokeballTintColor(lastPokemon.getPokeball(true)), 1, 250, "Sine.easeIn"); + switchOutPokemon.hideInfo(); + switchOutPokemon.tint(getPokeballTintColor(switchOutPokemon.getPokeball(true)), 1, 250, "Sine.easeIn"); globalScene.tweens.add({ - targets: lastPokemon, + targets: switchOutPokemon, duration: 250, ease: "Sine.easeIn", scale: 0.5, onComplete: () => { globalScene.time.delayedCall(750, () => this.switchAndSummon()); + switchOutPokemon.leaveField(this.switchType === SwitchType.SWITCH, false); }, }); } @@ -161,17 +170,9 @@ export class SwitchSummonPhase extends SummonPhase { } } - // Swap around the 2 pokemon's party positions and play an animation to send in the new pokemon. party[this.slotIndex] = this.lastPokemon; party[this.fieldIndex] = switchedInPokemon; const showTextAndSummon = () => { - // We don't reset temp effects here as we need to transfer them to tne new pokemon - // TODO: When should this remove the info container? - // Force switch moves did it prior - this.lastPokemon.leaveField( - ![SwitchType.BATON_PASS, SwitchType.SHED_TAIL].includes(this.switchType), - this.doReturn, - ); globalScene.ui.showText( this.player ? i18next.t("battle:playerGo", { diff --git a/test/abilities/wimp_out.test.ts b/test/abilities/wimp_out.test.ts index d1773f3e84e..f504a92caca 100644 --- a/test/abilities/wimp_out.test.ts +++ b/test/abilities/wimp_out.test.ts @@ -14,6 +14,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite import { BattleType } from "#enums/battle-type"; import { HitResult } from "#app/field/pokemon"; import type { ModifierOverride } from "#app/modifier/modifier-type"; +import type { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; describe("Abilities - Wimp Out", () => { let phaserGame: Phaser.Game; @@ -64,7 +65,7 @@ describe("Abilities - Wimp Out", () => { expect(pokemon1.species.speciesId).toBe(Species.WIMPOD); expect(pokemon1.isFainted()).toBe(false); - expect(pokemon1.getHpRatio()).toBeLessThan(0.5); + expect(pokemon1.getHpRatio(true)).toBeLessThan(0.5); } it("should switch the user out when falling below half HP, canceling its subsequent moves", async () => { @@ -86,8 +87,11 @@ describe("Abilities - Wimp Out", () => { expect(wimpod.turnData.acted).toBe(false); }); - it("should not trigger if user faints from damage", async () => { - game.override.enemyMoveset(Moves.BRAVE_BIRD).enemyLevel(1000); + it("should not trigger if user faints from damage and is revived", async () => { + game.override + .startingHeldItems([{ name: "REVIVER_SEED", count: 1 }]) + .enemyMoveset(Moves.BRAVE_BIRD) + .enemyLevel(1000); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); const wimpod = game.scene.getPlayerPokemon()!; @@ -95,9 +99,12 @@ describe("Abilities - Wimp Out", () => { game.move.select(Moves.SPLASH); game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.toNextTurn(); - expect(wimpod.isFainted()).toBe(true); + expect(wimpod.isFainted()).toBe(false); + expect(wimpod.isOnField()).toBe(true); + expect(wimpod.getHpRatio()).toBeCloseTo(0.5); + expect(wimpod.getHeldItems()).toHaveLength(0); expect(wimpod.waveData.abilitiesApplied).not.toContain(Abilities.WIMP_OUT); }); @@ -185,34 +192,41 @@ describe("Abilities - Wimp Out", () => { }); it("should block U-turn or Volt Switch on activation", async () => { - game.override.enemyMoveset(Moves.U_TURN); + game.override.battleType(BattleType.TRAINER).enemyMoveset(Moves.U_TURN); await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]); + const ninjask = game.scene.getEnemyPokemon()!; game.move.select(Moves.SPLASH); game.doSelectPartyPokemon(1); await game.phaseInterceptor.to("TurnEndPhase"); - const enemyPokemon = game.scene.getEnemyPokemon()!; - const hasFled = enemyPokemon.switchOutStatus; - expect(hasFled).toBe(false); confirmSwitch(); + expect(ninjask.isOnField()).toBe(true); }); it("should not block U-turn or Volt Switch if not activated", async () => { - game.override.enemyMoveset(Moves.U_TURN).battleType(BattleType.TRAINER); + game.override.battleType(BattleType.TRAINER).enemyMoveset(Moves.U_TURN).battleType(BattleType.TRAINER); await game.classicMode.startBattle([Species.GOLISOPOD, Species.TYRUNT]); - const ninjask1 = game.scene.getEnemyPokemon()!; - vi.spyOn(game.scene.getPlayerPokemon()!, "getAttackDamage").mockReturnValue({ + const wimpod = game.scene.getPlayerPokemon()!; + const ninjask = game.scene.getEnemyPokemon()!; + + // force enemy u turn to do 1 dmg + vi.spyOn(wimpod, "getAttackDamage").mockReturnValue({ cancelled: false, damage: 1, result: HitResult.EFFECTIVE, }); game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("SwitchSummonPhase", false); + const switchSummonPhase = game.scene.getCurrentPhase() as SwitchSummonPhase; + expect(switchSummonPhase.getPokemon()).toBe(ninjask); + await game.phaseInterceptor.to("TurnEndPhase"); - expect(ninjask1.isOnField()).toBe(true); + expect(wimpod.isOnField()).toBe(true); + expect(ninjask.isOnField()).toBe(false); }); it("should not activate from Dragon Tail and Circle Throw", async () => { @@ -242,7 +256,7 @@ describe("Abilities - Wimp Out", () => { { type: "status", enemyMove: Moves.TOXIC }, { type: "Ghost-type Curse", enemyMove: Moves.CURSE }, { type: "Salt Cure", enemyMove: Moves.SALT_CURE }, - { type: "partial trapping moves", enemyMove: Moves.WHIRLPOOL }, // no guard passive makes this guaranteed + { type: "partial trapping moves", enemyMove: Moves.WHIRLPOOL }, // no guard passive makes this 100% accurate { type: "Leech Seed", enemyMove: Moves.LEECH_SEED }, { type: "Powder", playerMove: Moves.EMBER, enemyMove: Moves.POWDER }, { type: "Nightmare", playerPassive: Abilities.COMATOSE, enemyMove: Moves.NIGHTMARE }, @@ -254,10 +268,11 @@ describe("Abilities - Wimp Out", () => { playerMove = Moves.SPLASH, playerPassive = Abilities.NONE, enemyMove = Moves.SPLASH, - enemyAbility = Abilities.BALL_FETCH, + enemyAbility = Abilities.STURDY, }) => { game.override .moveset(playerMove) + .enemyLevel(1) .passiveAbility(playerPassive) .enemySpecies(Species.GASTLY) .enemyMoveset(enemyMove) @@ -266,7 +281,7 @@ describe("Abilities - Wimp Out", () => { const wimpod = game.scene.getPlayerPokemon()!; expect(wimpod).toBeDefined(); - wimpod.hp = toDmgValue(wimpod.getMaxHp() / 2 + 5); + wimpod.hp = toDmgValue(wimpod.getMaxHp() / 2 + 2); // mock enemy attack damage func to only do 1 dmg (for whirlpool) vi.spyOn(wimpod, "getAttackDamage").mockReturnValueOnce({ cancelled: false, @@ -342,18 +357,16 @@ describe("Abilities - Wimp Out", () => { game.move.select(Moves.SUBSTITUTE); await game.forceEnemyMove(Moves.TIDY_UP); - game.doSelectPartyPokemon(1); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.toNextTurn(); confirmNoSwitch(); - // Turn 2: get back enough HP that substitute doesn't put us under - wimpod.hp = wimpod.getMaxHp() * 0.78; + wimpod.hp = wimpod.getMaxHp() * 0.8; game.move.select(Moves.SUBSTITUTE); - game.doSelectPartyPokemon(1); await game.forceEnemyMove(Moves.ROUND); + game.doSelectPartyPokemon(1); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.phaseInterceptor.to("TurnEndPhase"); @@ -413,7 +426,7 @@ describe("Abilities - Wimp Out", () => { game.move.select(Moves.SPLASH); await game.toNextTurn(); - confirmNoSwitch(); + expect(wimpod.isOnField()).toBe(true); expect(wimpod.getHpRatio()).toBeCloseTo(0.51); }); @@ -548,7 +561,7 @@ describe("Abilities - Wimp Out", () => { await game.classicMode.startBattle([Species.REGIDRAGO, Species.MAGIKARP]); - // turn 1 + // turn 1 - 1st wimpod faints while the 2nd one flees game.move.select(Moves.DRAGON_ENERGY, 0); game.move.select(Moves.SPLASH, 1); await game.forceEnemyMove(Moves.SPLASH); diff --git a/test/items/reviver_seed.test.ts b/test/items/reviver_seed.test.ts index 3c67481a904..cd18a27cae6 100644 --- a/test/items/reviver_seed.test.ts +++ b/test/items/reviver_seed.test.ts @@ -1,10 +1,10 @@ import { BattlerIndex } from "#app/battle"; -import { allMoves } from "#app/data/moves/move"; -import { BattlerTagType } from "#app/enums/battler-tag-type"; -import type { PokemonInstantReviveModifier } from "#app/modifier/modifier"; +import { HitResult } from "#app/field/pokemon"; +import { PokemonInstantReviveModifier } from "#app/modifier/modifier"; import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; +import { Stat } from "#enums/stat"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -26,77 +26,106 @@ describe("Items - Reviver Seed", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset([Moves.SPLASH, Moves.TACKLE, Moves.ENDURE]) + .moveset([Moves.SPLASH, Moves.TACKLE, Moves.LUMINA_CRASH]) .ability(Abilities.BALL_FETCH) .battleStyle("single") .disableCrits() .enemySpecies(Species.MAGIKARP) - .enemyAbility(Abilities.BALL_FETCH) + .enemyAbility(Abilities.NO_GUARD) .startingHeldItems([{ name: "REVIVER_SEED" }]) .enemyHeldItems([{ name: "REVIVER_SEED" }]) - .enemyMoveset(Moves.SPLASH); - vi.spyOn(allMoves[Moves.SHEER_COLD], "accuracy", "get").mockReturnValue(100); - vi.spyOn(allMoves[Moves.LEECH_SEED], "accuracy", "get").mockReturnValue(100); - vi.spyOn(allMoves[Moves.WHIRLPOOL], "accuracy", "get").mockReturnValue(100); - vi.spyOn(allMoves[Moves.WILL_O_WISP], "accuracy", "get").mockReturnValue(100); - }); - - it.each([ - { moveType: "Special Move", move: Moves.WATER_GUN }, - { moveType: "Physical Move", move: Moves.TACKLE }, - { moveType: "Fixed Damage Move", move: Moves.SEISMIC_TOSS }, - { moveType: "Final Gambit", move: Moves.FINAL_GAMBIT }, - { moveType: "Counter", move: Moves.COUNTER }, - { moveType: "OHKO", move: Moves.SHEER_COLD }, - ])("should activate the holder's reviver seed from a $moveType", async ({ move }) => { - game.override.enemyLevel(100).startingLevel(1).enemyMoveset(move); - await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); - const player = game.scene.getPlayerPokemon()!; - player.damageAndUpdate(player.hp - 1); - - const reviverSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier; - vi.spyOn(reviverSeed, "apply"); - - game.move.select(Moves.TACKLE); - await game.phaseInterceptor.to("BerryPhase"); - - expect(player.isFainted()).toBeFalsy(); - }); - - it("should activate the holder's reviver seed from confusion self-hit", async () => { - game.override.enemyLevel(1).startingLevel(100).enemyMoveset(Moves.SPLASH); - await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); - const player = game.scene.getPlayerPokemon()!; - player.damageAndUpdate(player.hp - 1); - player.addTag(BattlerTagType.CONFUSED, 3); - - const reviverSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier; - vi.spyOn(reviverSeed, "apply"); - - vi.spyOn(player, "randBattleSeedInt").mockReturnValue(0); // Force confusion self-hit - game.move.select(Moves.TACKLE); - await game.phaseInterceptor.to("BerryPhase"); - - expect(player.isFainted()).toBeFalsy(); - }); - - // Damaging opponents tests - it.each([ - { moveType: "Damaging Move Chip Damage", move: Moves.SALT_CURE }, - { moveType: "Chip Damage", move: Moves.LEECH_SEED }, - { moveType: "Trapping Chip Damage", move: Moves.WHIRLPOOL }, - { moveType: "Status Effect Damage", move: Moves.WILL_O_WISP }, - { moveType: "Weather", move: Moves.SANDSTORM }, - ])("should not activate the holder's reviver seed from $moveType", async ({ move }) => { - game.override - .enemyLevel(1) + .enemyMoveset(Moves.SPLASH) .startingLevel(100) - .enemySpecies(Species.MAGIKARP) - .moveset(move) - .enemyMoveset(Moves.ENDURE); + .enemyLevel(100); // makes hp tests more accurate due to rounding + }); + + it("should be consumed upon fainting to revive the holder, removing temporary effects and healing to 50% max HP", async () => { + await game.classicMode.startBattle([Species.MAGIKARP]); + + const enemy = game.scene.getEnemyPokemon()!; + enemy.hp = 1; + enemy.setStatStage(Stat.ATK, 6); + + expect(enemy.getHeldItems()[0]).toBeInstanceOf(PokemonInstantReviveModifier); + game.move.select(Moves.TACKLE); + await game.phaseInterceptor.to("TurnEndPhase"); + + // Enemy ate seed, was revived and healed to half HP, clearing its attack boost at the same time. + expect(enemy.isFainted()).toBeFalsy(); + expect(enemy.getHpRatio()).toBeCloseTo(0.5); + expect(enemy.getHeldItems()[0]).toBeUndefined(); + expect(enemy.getStatStage(Stat.ATK)).toBe(0); + expect(enemy.turnData.acted).toBe(true); + }); + + it("should nullify move effects on the killing blow and interrupt multi hits", async () => { + // Give player a 4 hit lumina crash that lowers spdef by 2 stages per hit + game.override.ability(Abilities.PARENTAL_BOND).startingHeldItems([ + { name: "REVIVER_SEED", count: 1 }, + { name: "MULTI_LENS", count: 2 }, + ]); + await game.classicMode.startBattle([Species.MAGIKARP]); + + // give enemy 3 hp, dying 3 hits into the move + const enemy = game.scene.getEnemyPokemon()!; + enemy.hp = 3; + vi.spyOn(enemy, "getAttackDamage").mockReturnValue({ cancelled: false, damage: 1, result: HitResult.EFFECTIVE }); + + game.move.select(Moves.LUMINA_CRASH); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.phaseInterceptor.to("FaintPhase", false); + expect(enemy.getStatStage(Stat.SPDEF)).toBe(-4); // killing hit effect got nullified due to fainting the target + expect(enemy.getAttackDamage).toHaveBeenCalledTimes(3); + + await game.phaseInterceptor.to("TurnEndPhase"); + + // Attack was cut short due to lack of targets, after which the enemy was revived and their stat stages reset + expect(enemy.isFainted()).toBeFalsy(); + expect(enemy.getStatStage(Stat.SPDEF)).toBe(0); + expect(enemy.getHpRatio()).toBeCloseTo(0.5); + expect(enemy.getHeldItems()[0]).toBeUndefined(); + + const player = game.scene.getPlayerPokemon()!; + expect(player.turnData.hitsLeft).toBe(1); + }); + + it.each([ + { moveType: "Special Moves", move: Moves.WATER_GUN }, + { moveType: "Physical Moves", move: Moves.TACKLE }, + { moveType: "Fixed Damage Moves", move: Moves.SEISMIC_TOSS }, + { moveType: "Final Gambit", move: Moves.FINAL_GAMBIT }, + { moveType: "Counter Moves", move: Moves.COUNTER }, + { moveType: "OHKOs", move: Moves.SHEER_COLD }, + { moveType: "Confusion Self-hits", move: Moves.CONFUSE_RAY }, + ])("should activate from $moveType", async ({ move }) => { + game.override.enemyMoveset(move).confusionActivation(true); + await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); + const player = game.scene.getPlayerPokemon()!; + player.hp = 1; + + const reviverSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier; + const seedSpy = vi.spyOn(reviverSeed, "apply"); + + game.move.select(Moves.TACKLE); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.phaseInterceptor.to("BerryPhase"); + + expect(player.isFainted()).toBe(false); + expect(seedSpy).toHaveBeenCalled(); + }); + + // Damaging tests + it.each([ + { moveType: "Salt Cure", move: Moves.SALT_CURE }, + { moveType: "Leech Seed", move: Moves.LEECH_SEED }, + { moveType: "Partial Trapping Move", move: Moves.WHIRLPOOL }, + { moveType: "Status Effect", move: Moves.WILL_O_WISP }, + { moveType: "Weather", move: Moves.SANDSTORM }, + ])("should not activate from $moveType damage", async ({ move }) => { + game.override.moveset(move).enemyMoveset(Moves.ENDURE); await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); const enemy = game.scene.getEnemyPokemon()!; - enemy.damageAndUpdate(enemy.hp - 1); + enemy.hp = 1; game.move.select(move); await game.phaseInterceptor.to("TurnEndPhase"); @@ -107,31 +136,26 @@ describe("Items - Reviver Seed", () => { // Self-damage tests it.each([ { moveType: "Recoil", move: Moves.DOUBLE_EDGE }, - { moveType: "Self-KO", move: Moves.EXPLOSION }, - { moveType: "Self-Deduction", move: Moves.CURSE }, + { moveType: "Sacrificial", move: Moves.EXPLOSION }, + { moveType: "Ghost-type Curse", move: Moves.CURSE }, { moveType: "Liquid Ooze", move: Moves.GIGA_DRAIN }, - ])("should not activate the holder's reviver seed from $moveType", async ({ move }) => { - game.override - .enemyLevel(100) - .startingLevel(1) - .enemySpecies(Species.MAGIKARP) - .moveset(move) - .enemyAbility(Abilities.LIQUID_OOZE) - .enemyMoveset(Moves.SPLASH); + ])("should not activate from $moveType self-damage", async ({ move }) => { + game.override.moveset(move).enemyAbility(Abilities.LIQUID_OOZE); await game.classicMode.startBattle([Species.GASTLY, Species.FEEBAS]); const player = game.scene.getPlayerPokemon()!; - player.damageAndUpdate(player.hp - 1); + player.hp = 1; const playerSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier; - vi.spyOn(playerSeed, "apply"); + const seedSpy = vi.spyOn(playerSeed, "apply"); game.move.select(move); await game.phaseInterceptor.to("TurnEndPhase"); expect(player.isFainted()).toBeTruthy(); + expect(seedSpy).not.toHaveBeenCalled(); }); - it("should not activate the holder's reviver seed from Destiny Bond fainting", async () => { + it("should not activate from Destiny Bond fainting", async () => { game.override .enemyLevel(100) .startingLevel(1) @@ -141,7 +165,7 @@ describe("Items - Reviver Seed", () => { .enemyMoveset(Moves.TACKLE); await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); const player = game.scene.getPlayerPokemon()!; - player.damageAndUpdate(player.hp - 1); + player.hp = 1; const enemy = game.scene.getEnemyPokemon()!; game.move.select(Moves.DESTINY_BOND); diff --git a/test/moves/dragon_tail.test.ts b/test/moves/dragon_tail.test.ts index 98b1436ffb8..4e65cd4bac5 100644 --- a/test/moves/dragon_tail.test.ts +++ b/test/moves/dragon_tail.test.ts @@ -156,7 +156,7 @@ describe("Moves - Dragon Tail", () => { expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp()); }); - it("should not switch out a target with suction cups", async () => { + it("should not switch out a target with suction cups, unless the user has Mold Breaker", async () => { game.override.enemyAbility(Abilities.SUCTION_CUPS); await game.classicMode.startBattle([Species.REGIELEKI]); @@ -167,6 +167,16 @@ describe("Moves - Dragon Tail", () => { expect(enemy.isOnField()).toBe(true); expect(enemy.isFullHp()).toBe(false); + + // Turn 2: Mold Breaker should ignore switch blocking ability and switch out the target + game.override.ability(Abilities.MOLD_BREAKER); + enemy.hp = enemy.getMaxHp(); + + game.move.select(Moves.DRAGON_TAIL); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(enemy.isOnField()).toBe(false); + expect(enemy.isFullHp()).toBe(false); }); it("should not switch out a Commanded Dondozo", async () => { @@ -203,43 +213,47 @@ describe("Moves - Dragon Tail", () => { expect(game.scene.getEnemyField().length).toBe(1); }); - it("should not cause a softlock when activating an opponent trainer's reviver seed", async () => { + it("should neither switch nor softlock when activating an opponent's reviver seed", async () => { game.override - .startingWave(5) + .battleType(BattleType.TRAINER) .enemyHeldItems([{ name: "REVIVER_SEED" }]) - .startingLevel(1000); // To make sure Dragon Tail KO's the opponent + .startingLevel(1000); // make sure Dragon Tail KO's the opponent await game.classicMode.startBattle([Species.DRATINI]); - game.move.select(Moves.DRAGON_TAIL); + const [wailord1, wailord2] = game.scene.getEnemyParty()!; + expect(wailord1).toBeDefined(); + expect(wailord2).toBeDefined(); + game.move.select(Moves.DRAGON_TAIL); await game.toNextTurn(); - // Make sure the enemy field is not empty and has a revived Pokemon - const enemy = game.scene.getEnemyPokemon()!; - expect(enemy).toBeDefined(); - expect(enemy.hp).toBe(Math.floor(enemy.getMaxHp() / 2)); - expect(game.scene.getEnemyField().length).toBe(1); + // Wailord should have consumed the reviver seed and stayed on field + expect(wailord1.isOnField()).toBe(true); + expect(wailord1.getHpRatio()).toBeCloseTo(0.5); + expect(wailord1.getHeldItems()).toHaveLength(0); + expect(wailord2.isOnField()).toBe(false); }); - it("should not cause a softlock when activating a player's reviver seed", async () => { + it("should neither switch nor softlock when activating a player's reviver seed", async () => { game.override .startingHeldItems([{ name: "REVIVER_SEED" }]) .enemyMoveset(Moves.DRAGON_TAIL) - .enemyLevel(1000); // To make sure Dragon Tail KO's the player - await game.classicMode.startBattle([Species.DRATINI, Species.BULBASAUR]); + .enemyLevel(1000); // make sure Dragon Tail KO's the player + await game.classicMode.startBattle([Species.BLISSEY, Species.BULBASAUR]); + + const [blissey, bulbasaur] = game.scene.getPlayerParty(); game.move.select(Moves.SPLASH); - await game.toNextTurn(); - // Make sure the player's field is not empty and has a revived Pokemon - const dratini = game.scene.getPlayerPokemon()!; - expect(dratini).toBeDefined(); - expect(dratini.hp).toBe(Math.floor(dratini.getMaxHp() / 2)); - expect(game.scene.getPlayerField().length).toBe(1); + // dratini should have consumed the reviver seed and stayed on field + expect(blissey.isOnField()).toBe(true); + expect(blissey.getHpRatio()).toBeCloseTo(0.5); + expect(blissey.getHeldItems()).toHaveLength(0); + expect(bulbasaur.isOnField()).toBe(false); }); - it("should force switches randomly", async () => { + it("should force switches to a random off-field pokemon", async () => { game.override.enemyMoveset(Moves.DRAGON_TAIL).startingLevel(100).enemyLevel(1); await game.classicMode.startBattle([Species.BULBASAUR, Species.CHARMANDER, Species.SQUIRTLE]); @@ -279,6 +293,7 @@ describe("Moves - Dragon Tail", () => { const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty(); expect(toxapex).toBeDefined(); + toxapex.hp = 0; // Mock an RNG call to switch to the first eligible pokemon. // Eevee is ineligible and Toxapex is fainted, so it should proc on Primarina instead @@ -286,7 +301,6 @@ describe("Moves - Dragon Tail", () => { return min; }); game.move.select(Moves.SPLASH); - await game.killPokemon(toxapex); await game.toNextTurn(); expect(lapras.isOnField()).toBe(false); @@ -309,14 +323,15 @@ describe("Moves - Dragon Tail", () => { game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); await game.forceEnemyMove(Moves.DRAGON_TAIL, BattlerIndex.PLAYER); await game.forceEnemyMove(Moves.DRAGON_TAIL, BattlerIndex.PLAYER_2); - await game.killPokemon(cloyster); + await game.killPokemon(kyogre); + game.doSelectPartyPokemon(3); await game.toNextTurn(); - // Eevee is ineligble due to challenge and cloyster is fainted, leaving no backup pokemon able to switch in + // Eevee is ineligble due to challenge and kyogre is fainted, leaving no backup pokemon able to switch in expect(lapras.isOnField()).toBe(true); - expect(kyogre.isOnField()).toBe(true); + expect(kyogre.isOnField()).toBe(false); expect(eevee.isOnField()).toBe(false); - expect(cloyster.isOnField()).toBe(false); + expect(cloyster.isOnField()).toBe(true); expect(lapras.getInverseHp()).toBeGreaterThan(0); expect(kyogre.getInverseHp()).toBeGreaterThan(0); expect(game.scene.getBackupPartyMemberIndices(true)).toHaveLength(0);