[BUG] - dragon tail switchout ability in wild battles proc crashes game (#3346)

* fixing switchout ability doubles bug, refactor move redirect code

* added unit test for dragon tail

* updating test

* addressing errors from pages deployment

* pages deployment still failing

* typedoc

* please let this be the one

* formatting and test fixing

* await starting battle should go after overrides
This commit is contained in:
DustinLin 2024-08-07 17:44:34 -07:00 committed by GitHub
parent a272d28cdf
commit 8faf27efc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 223 additions and 16 deletions

View File

@ -768,6 +768,27 @@ export default class BattleScene extends SceneBase {
: ret;
}
/**
* Used in doubles battles to redirect moves from one pokemon to another when one faints or is removed from the field
* @param removedPokemon {@linkcode Pokemon} the pokemon that is being removed from the field (flee, faint), moves to be redirected FROM
* @param allyPokemon {@linkcode Pokemon} the pokemon that will have the moves be redirected TO
*/
redirectPokemonMoves(removedPokemon: Pokemon, allyPokemon: Pokemon): void {
// failsafe: if not a double battle just return
if (this.currentBattle.double === false) {
return;
}
if (allyPokemon?.isActive(true)) {
let targetingMovePhase: MovePhase;
do {
targetingMovePhase = this.findPhase(mp => mp instanceof MovePhase && mp.targets.length === 1 && mp.targets[0] === removedPokemon.getBattlerIndex() && mp.pokemon.isPlayer() !== allyPokemon.isPlayer()) as MovePhase;
if (targetingMovePhase && targetingMovePhase.targets[0] !== allyPokemon.getBattlerIndex()) {
targetingMovePhase.targets[0] = allyPokemon.getBattlerIndex();
}
} while (targetingMovePhase);
}
}
/**
* Returns the ModifierBar of this scene, which is declared private and therefore not accessible elsewhere
* @returns {ModifierBar}

View File

@ -4883,13 +4883,18 @@ export class ForceSwitchOutAttr extends MoveEffectAttr {
user.scene.prependToPhase(new SwitchSummonPhase(user.scene, switchOutTarget.getFieldIndex(), (user.scene.currentBattle.trainer ? user.scene.currentBattle.trainer.getNextSummonIndex((switchOutTarget as EnemyPokemon).trainerSlot) : 0), false, this.batonPass, false), MoveEndPhase);
}
} else {
// Switch out logic for everything else
switchOutTarget.setVisible(false);
// Switch out logic for everything else (eg: WILD battles)
switchOutTarget.leaveField(false);
if (switchOutTarget.hp) {
switchOutTarget.hideInfo().then(() => switchOutTarget.destroy());
switchOutTarget.scene.field.remove(switchOutTarget);
switchOutTarget.setWildFlee(true);
user.scene.queueMessage(i18next.t("moveTriggers:fled", {pokemonName: getPokemonNameWithAffix(switchOutTarget)}), null, true, 500);
// in double battles redirect potential moves off fled pokemon
if (switchOutTarget.scene.currentBattle.double) {
const allyPokemon = switchOutTarget.getAlly();
switchOutTarget.scene.redirectPokemonMoves(switchOutTarget, allyPokemon);
}
}
if (!switchOutTarget.getAlly()?.isActive(true)) {
@ -7585,7 +7590,8 @@ export function initMoves() {
new AttackMove(Moves.FROST_BREATH, Type.ICE, MoveCategory.SPECIAL, 60, 90, 10, 100, 0, 5)
.attr(CritOnlyAttr),
new AttackMove(Moves.DRAGON_TAIL, Type.DRAGON, MoveCategory.PHYSICAL, 60, 90, 10, -1, -6, 5)
.attr(ForceSwitchOutAttr),
.attr(ForceSwitchOutAttr)
.hidesTarget(),
new SelfStatusMove(Moves.WORK_UP, Type.NORMAL, -1, 30, -1, 0, 5)
.attr(StatChangeAttr, [ BattleStat.ATK, BattleStat.SPATK ], 1, true),
new AttackMove(Moves.ELECTROWEB, Type.ELECTRIC, MoveCategory.SPECIAL, 55, 95, 15, 100, 0, 5)

View File

@ -88,6 +88,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
public luck: integer;
public pauseEvolutions: boolean;
public pokerus: boolean;
public wildFlee: boolean;
public fusionSpecies: PokemonSpecies | null;
public fusionFormIndex: integer;
@ -129,6 +130,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
this.species = species;
this.pokeball = dataSource?.pokeball || PokeballType.POKEBALL;
this.level = level;
this.wildFlee = false;
// Determine the ability index
if (abilityIndex !== undefined) {
this.abilityIndex = abilityIndex; // Use the provided ability index if it is defined
@ -298,14 +301,14 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
}
/**
* Check if this pokemon is both not fainted and allowed to be in battle.
* Check if this pokemon is both not fainted (or a fled wild pokemon) and allowed to be in battle.
* This is frequently a better alternative to {@link isFainted}
* @returns {boolean} True if pokemon is allowed in battle
*/
isAllowedInBattle(): boolean {
const challengeAllowed = new Utils.BooleanHolder(true);
applyChallenges(this.scene.gameMode, ChallengeType.POKEMON_IN_BATTLE, this, challengeAllowed);
return !this.isFainted() && challengeAllowed.value;
return !this.isFainted() && !this.wildFlee && challengeAllowed.value;
}
isActive(onField?: boolean): boolean {
@ -1779,6 +1782,14 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
});
}
/**
* sets if the pokemon has fled (implies it's a wild pokemon)
* @param status - boolean
*/
setWildFlee(status: boolean): void {
this.wildFlee = status;
}
updateInfo(instant?: boolean): Promise<void> {
return this.battleInfo.updateInfo(this, instant);
}

View File

@ -2576,6 +2576,15 @@ export class BattleEndPhase extends BattlePhase {
this.scene.updateModifiers().then(() => this.end());
}
end() {
// removing pokemon at the end of a battle
for (const p of this.scene.getEnemyParty()) {
p.destroy();
}
super.end();
}
}
export class NewBattlePhase extends BattlePhase {
@ -3830,6 +3839,7 @@ export class FaintPhase extends PokemonPhase {
doFaint(): void {
const pokemon = this.getPokemon();
// Track total times pokemon have been KO'd for supreme overlord/last respects
if (pokemon.isPlayer()) {
this.scene.currentBattle.playerFaints += 1;
@ -3880,17 +3890,10 @@ export class FaintPhase extends PokemonPhase {
}
}
// in double battles redirect potential moves off fainted pokemon
if (this.scene.currentBattle.double) {
const allyPokemon = pokemon.getAlly();
if (allyPokemon?.isActive(true)) {
let targetingMovePhase: MovePhase;
do {
targetingMovePhase = this.scene.findPhase(mp => mp instanceof MovePhase && mp.targets.length === 1 && mp.targets[0] === pokemon.getBattlerIndex() && mp.pokemon.isPlayer() !== allyPokemon.isPlayer()) as MovePhase;
if (targetingMovePhase && targetingMovePhase.targets[0] !== allyPokemon.getBattlerIndex()) {
targetingMovePhase.targets[0] = allyPokemon.getBattlerIndex();
}
} while (targetingMovePhase);
}
this.scene.redirectPokemonMoves(pokemon, allyPokemon);
}
pokemon.lapseTags(BattlerTagLapseType.FAINT);

View File

@ -0,0 +1,166 @@
import { allMoves } from "#app/data/move.js";
import { SPLASH_ONLY } from "../utils/testUtils";
import { BattleEndPhase, BerryPhase, TurnEndPhase} from "#app/phases.js";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import GameManager from "../utils/gameManager";
import { getMovePosition } from "../utils/gameManagerUtils";
import { BattlerIndex } from "#app/battle.js";
const TIMEOUT = 20 * 1000;
describe("Moves - Dragon Tail", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override.battleType("single")
.moveset([Moves.DRAGON_TAIL, Moves.SPLASH])
.enemySpecies(Species.WAILORD)
.enemyMoveset(SPLASH_ONLY)
.startingLevel(5)
.enemyLevel(5);
vi.spyOn(allMoves[Moves.DRAGON_TAIL], "accuracy", "get").mockReturnValue(100);
});
test(
"Single battle should cause opponent to flee, and not crash",
async () => {
await game.startBattle([Species.DRATINI]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
expect(enemyPokemon).toBeDefined();
game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_TAIL));
await game.phaseInterceptor.to(BerryPhase);
const isVisible = enemyPokemon.visible;
const hasFled = enemyPokemon.wildFlee;
expect(!isVisible && hasFled).toBe(true);
// simply want to test that the game makes it this far without crashing
await game.phaseInterceptor.to(BattleEndPhase);
}, TIMEOUT
);
test(
"Single battle should cause opponent to flee, display ability, and not crash",
async () => {
game.override.enemyAbility(Abilities.ROUGH_SKIN);
await game.startBattle([Species.DRATINI]);
const leadPokemon = game.scene.getPlayerPokemon()!;
expect(leadPokemon).toBeDefined();
const enemyPokemon = game.scene.getEnemyPokemon()!;
expect(enemyPokemon).toBeDefined();
game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_TAIL));
await game.phaseInterceptor.to(BerryPhase);
const isVisible = enemyPokemon.visible;
const hasFled = enemyPokemon.wildFlee;
expect(!isVisible && hasFled).toBe(true);
expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp());
}, TIMEOUT
);
test(
"Double battles should proceed without crashing" ,
async () => {
game.override.battleType("double").enemyMoveset(SPLASH_ONLY);
game.override.moveset([Moves.DRAGON_TAIL, Moves.SPLASH, Moves.FLAMETHROWER])
.enemyAbility(Abilities.ROUGH_SKIN);
await game.startBattle([Species.DRATINI, Species.DRATINI, Species.WAILORD, Species.WAILORD]);
const leadPokemon = game.scene.getParty()[0]!;
const secPokemon = game.scene.getParty()[1]!;
expect(leadPokemon).toBeDefined();
expect(secPokemon).toBeDefined();
const enemyLeadPokemon = game.scene.currentBattle.enemyParty[0]!;
const enemySecPokemon = game.scene.currentBattle.enemyParty[1]!;
expect(enemyLeadPokemon).toBeDefined();
expect(enemySecPokemon).toBeDefined();
game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_TAIL));
game.doSelectTarget(BattlerIndex.ENEMY);
game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH));
await game.phaseInterceptor.to(TurnEndPhase);
const isVisibleLead = enemyLeadPokemon.visible;
const hasFledLead = enemyLeadPokemon.wildFlee;
const isVisibleSec = enemySecPokemon.visible;
const hasFledSec = enemySecPokemon.wildFlee;
expect(!isVisibleLead && hasFledLead && isVisibleSec && !hasFledSec).toBe(true);
expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp());
// second turn
game.doAttack(getMovePosition(game.scene, 0, Moves.FLAMETHROWER));
game.doSelectTarget(BattlerIndex.ENEMY_2);
game.doAttack(getMovePosition(game.scene, 1, Moves.SPLASH));
await game.phaseInterceptor.to(BerryPhase);
expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp());
}, TIMEOUT
);
test(
"Flee move redirection works" ,
async () => {
game.override.battleType("double").enemyMoveset(SPLASH_ONLY);
game.override.moveset([Moves.DRAGON_TAIL, Moves.SPLASH, Moves.FLAMETHROWER]);
game.override.enemyAbility(Abilities.ROUGH_SKIN);
await game.startBattle([Species.DRATINI, Species.DRATINI, Species.WAILORD, Species.WAILORD]);
const leadPokemon = game.scene.getParty()[0]!;
const secPokemon = game.scene.getParty()[1]!;
expect(leadPokemon).toBeDefined();
expect(secPokemon).toBeDefined();
const enemyLeadPokemon = game.scene.currentBattle.enemyParty[0]!;
const enemySecPokemon = game.scene.currentBattle.enemyParty[1]!;
expect(enemyLeadPokemon).toBeDefined();
expect(enemySecPokemon).toBeDefined();
game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_TAIL));
game.doSelectTarget(BattlerIndex.ENEMY);
// target the same pokemon, second move should be redirected after first flees
game.doAttack(getMovePosition(game.scene, 0, Moves.DRAGON_TAIL));
game.doSelectTarget(BattlerIndex.ENEMY);
await game.phaseInterceptor.to(BerryPhase);
const isVisibleLead = enemyLeadPokemon.visible;
const hasFledLead = enemyLeadPokemon.wildFlee;
const isVisibleSec = enemySecPokemon.visible;
const hasFledSec = enemySecPokemon.wildFlee;
expect(!isVisibleLead && hasFledLead && !isVisibleSec && hasFledSec).toBe(true);
expect(leadPokemon.hp).toBeLessThan(leadPokemon.getMaxHp());
expect(secPokemon.hp).toBeLessThan(secPokemon.getMaxHp());
expect(enemyLeadPokemon.hp).toBeLessThan(enemyLeadPokemon.getMaxHp());
expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp());
}, TIMEOUT
);
});