From f57798fd1d7210282895a02c2be0dd85fe181f70 Mon Sep 17 00:00:00 2001 From: Greenlamp2 <44787002+Greenlamp2@users.noreply.github.com> Date: Wed, 12 Jun 2024 00:55:16 +0200 Subject: [PATCH] [Bug Fix] Correct PostSummonPhase Timing for Abilities and Entry Hazards (#2108) * added test for spikes + forceOpponentToSwitch * added conditionalQueue && pushConditionalPhase to fix entry hasard and abilities at post summon * reduce timeout time to default --- src/battle-scene.ts | 32 ++++++++ src/phases.ts | 20 +++-- src/test/moves/spikes.test.ts | 128 ++++++++++++++++++++++++++++++ src/test/utils/TextInterceptor.ts | 1 + src/test/utils/gameManager.ts | 17 +++- 5 files changed, 184 insertions(+), 14 deletions(-) create mode 100644 src/test/moves/spikes.test.ts diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 5d2e2888c64..8b469022e31 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -168,6 +168,7 @@ export default class BattleScene extends SceneBase { public sessionSlotId: integer; public phaseQueue: Phase[]; + public conditionalQueue: Array<[() => boolean, Phase]>; private phaseQueuePrepend: Phase[]; private phaseQueuePrependSpliceIndex: integer; private nextCommandPhaseQueue: Phase[]; @@ -252,6 +253,7 @@ export default class BattleScene extends SceneBase { super("battle"); this.phaseQueue = []; this.phaseQueuePrepend = []; + this.conditionalQueue = []; this.phaseQueuePrependSpliceIndex = -1; this.nextCommandPhaseQueue = []; this.updateGameInfo(); @@ -1843,6 +1845,21 @@ export default class BattleScene extends SceneBase { return this.standbyPhase; } + /** + * Adds a phase to the conditional queue and ensures it is executed only when the specified condition is met. + * + * This method allows deferring the execution of a phase until certain conditions are met, which is useful for handling + * situations like abilities and entry hazards that depend on specific game states. + * + * @param {Phase} phase - The phase to be added to the conditional queue. + * @param {() => boolean} condition - A function that returns a boolean indicating whether the phase should be executed. + * + */ + pushConditionalPhase(phase: Phase, condition: () => boolean): void { + this.conditionalQueue.push([condition, phase]); + } + + pushPhase(phase: Phase, defer: boolean = false): void { (!defer ? this.phaseQueue : this.nextCommandPhaseQueue).push(phase); } @@ -1886,6 +1903,21 @@ export default class BattleScene extends SceneBase { this.populatePhaseQueue(); } this.currentPhase = this.phaseQueue.shift(); + + // Check if there are any conditional phases queued + if (this.conditionalQueue?.length) { + // Retrieve the first conditional phase from the queue + const conditionalPhase = this.conditionalQueue.shift(); + // Evaluate the condition associated with the phase + if (conditionalPhase[0]()) { + // If the condition is met, add the phase to the front of the phase queue + this.unshiftPhase(conditionalPhase[1]); + } else { + // If the condition is not met, re-add the phase back to the front of the conditional queue + this.conditionalQueue.unshift(conditionalPhase); + } + } + this.currentPhase.start(); } diff --git a/src/phases.ts b/src/phases.ts index 04212e284a0..e649c8a291f 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -1024,6 +1024,15 @@ export class EncounterPhase extends BattlePhase { }); if (this.scene.currentBattle.battleType !== BattleType.TRAINER) { + enemyField.map(p => this.scene.pushConditionalPhase(new PostSummonPhase(this.scene, p.getBattlerIndex()), () => { + // is the player party initialized ? + const a = !!this.scene.getParty()?.length; + // how many player pokemon are on the field ? + const amountOnTheField = this.scene.getParty().filter(p => p.isOnField()).length; + // if it's a double, there should be 2, otherwise 1 + const b = this.scene.currentBattle.double ? amountOnTheField === 2 : amountOnTheField === 1; + return a && b; + })); const ivScannerModifier = this.scene.findModifier(m => m instanceof IvScannerModifier); if (ivScannerModifier) { enemyField.map(p => this.scene.pushPhase(new ScanIvsPhase(this.scene, p.getBattlerIndex(), Math.min(ivScannerModifier.getStackCount() * 2, 6)))); @@ -1479,10 +1488,6 @@ export class SummonPhase extends PartyMemberPokemonPhase { if (!this.loaded || this.scene.currentBattle.battleType === BattleType.TRAINER || (this.scene.currentBattle.waveIndex % 10) === 1) { this.scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); - } - if (pokemon.isPlayer()) { - // postSummon for player only here, since we want the postSummon from opponent to be call in the turnInitPhase - // covering both wild & trainer battles this.queuePostSummon(); } } @@ -1797,13 +1802,6 @@ export class TurnInitPhase extends FieldPhase { start() { super.start(); - const enemyField = this.scene.getEnemyField().filter(p => p.isActive()) as Pokemon[]; - enemyField.map(p => { - if (p.battleSummonData.turnCount !== 1) { - return; - } - return this.scene.unshiftPhase(new PostSummonPhase(this.scene, p.getBattlerIndex())); - }); this.scene.getPlayerField().forEach(p => { // If this pokemon is in play and evolved into something illegal under the current challenge, force a switch diff --git a/src/test/moves/spikes.test.ts b/src/test/moves/spikes.test.ts new file mode 100644 index 00000000000..e3d5fd77a44 --- /dev/null +++ b/src/test/moves/spikes.test.ts @@ -0,0 +1,128 @@ +import {afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest"; +import Phaser from "phaser"; +import GameManager from "#app/test/utils/gameManager"; +import * as overrides from "#app/overrides"; +import {Abilities} from "#app/data/enums/abilities"; +import {Species} from "#app/data/enums/species"; +import { + CommandPhase +} from "#app/phases"; +import {Moves} from "#app/data/enums/moves"; + + +describe("Moves - Spikes", () => { + 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.scene.battleStyle = 1; + vi.spyOn(overrides, "SINGLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(Species.RATTATA); + vi.spyOn(overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.HYDRATION); + vi.spyOn(overrides, "OPP_PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.HYDRATION); + vi.spyOn(overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.HYDRATION); + vi.spyOn(overrides, "PASSIVE_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.HYDRATION); + vi.spyOn(overrides, "STARTING_WAVE_OVERRIDE", "get").mockReturnValue(3); + vi.spyOn(overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH,Moves.SPLASH,Moves.SPLASH,Moves.SPLASH]); + vi.spyOn(overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPIKES,Moves.SPLASH, Moves.ROAR]); + }); + + it("single - wild - stay on field - no damage", async() => { + // player set spikes on the field and do splash for 3 turns + // opponent do splash for 4 turns + // nobody should take damage + await game.runToSummon([ + Species.MIGHTYENA, + Species.POOCHYENA, + ]); + await game.phaseInterceptor.to(CommandPhase, true); + const initialHp = game.scene.getParty()[0].hp; + expect(game.scene.getParty()[0].hp).toBe(initialHp); + game.doAttack(0); + await game.toNextTurn(); + game.doAttack(1); + await game.toNextTurn(); + game.doAttack(1); + await game.toNextTurn(); + game.doAttack(1); + await game.toNextTurn(); + game.doAttack(1); + await game.toNextTurn(); + expect(game.scene.getParty()[0].hp).toBe(initialHp); + console.log(game.textInterceptor.logs); + }, 20000); + + it("single - wild - take some damage", async() => { + // player set spikes on the field and switch back to back + // opponent do splash for 2 turns + // nobody should take damage + await game.runToSummon([ + Species.MIGHTYENA, + Species.POOCHYENA, + ]); + await game.phaseInterceptor.to(CommandPhase, false); + + const initialHp = game.scene.getParty()[0].hp; + await game.switchPokemon(1, false); + await game.phaseInterceptor.run(CommandPhase); + await game.phaseInterceptor.to(CommandPhase, false); + + await game.switchPokemon(1, false); + await game.phaseInterceptor.run(CommandPhase); + await game.phaseInterceptor.to(CommandPhase, false); + + expect(game.scene.getParty()[0].hp).toBe(initialHp); + }, 20000); + + it("trainer - wild - force switch opponent - should take damage", async() => { + vi.spyOn(overrides, "STARTING_WAVE_OVERRIDE", "get").mockReturnValue(5); + // player set spikes on the field and do splash for 3 turns + // opponent do splash for 4 turns + // nobody should take damage + await game.runToSummon([ + Species.MIGHTYENA, + Species.POOCHYENA, + ]); + await game.phaseInterceptor.to(CommandPhase, true); + const initialHpOpponent = game.scene.currentBattle.enemyParty[1].hp; + game.doAttack(0); + await game.toNextTurn(); + game.doAttack(2); + await game.toNextTurn(); + expect(game.scene.currentBattle.enemyParty[0].hp).toBeLessThan(initialHpOpponent); + }, 20000); + + it("trainer - wild - force switch by himself opponent - should take damage", async() => { + vi.spyOn(overrides, "STARTING_WAVE_OVERRIDE", "get").mockReturnValue(5); + vi.spyOn(overrides, "STARTING_LEVEL_OVERRIDE", "get").mockReturnValue(5000); + vi.spyOn(overrides, "OPP_SPECIES_OVERRIDE", "get").mockReturnValue(0); + // turn 1: player set spikes, opponent do splash + // turn 2: player do splash, opponent switch pokemon + // opponent pokemon should trigger spikes and lose HP + await game.runToSummon([ + Species.MIGHTYENA, + Species.POOCHYENA, + ]); + await game.phaseInterceptor.to(CommandPhase, true); + const initialHpOpponent = game.scene.currentBattle.enemyParty[1].hp; + game.doAttack(0); + await game.toNextTurn(); + + game.forceOpponentToSwitch(); + game.doAttack(1); + await game.toNextTurn(); + expect(game.scene.currentBattle.enemyParty[0].hp).toBeLessThan(initialHpOpponent); + }, 20000); + +}); diff --git a/src/test/utils/TextInterceptor.ts b/src/test/utils/TextInterceptor.ts index 4cb24b39042..34b55aa30ac 100644 --- a/src/test/utils/TextInterceptor.ts +++ b/src/test/utils/TextInterceptor.ts @@ -7,6 +7,7 @@ export default class TextInterceptor { } showText(text: string, delay?: integer, callback?: Function, callbackDelay?: integer, prompt?: boolean, promptDelay?: integer): void { + console.log(text); this.logs.push(text); } diff --git a/src/test/utils/gameManager.ts b/src/test/utils/gameManager.ts index e2ef97ac599..6035d382c24 100644 --- a/src/test/utils/gameManager.ts +++ b/src/test/utils/gameManager.ts @@ -29,6 +29,7 @@ import {Command} from "#app/ui/command-ui-handler"; import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; import {Button} from "#app/enums/buttons"; import PartyUiHandler, {PartyUiMode} from "#app/ui/party-ui-handler"; +import Trainer from "#app/field/trainer"; /** * Class to manage the game state and transitions between phases. @@ -192,6 +193,14 @@ export default class GameManager { }, () => this.isCurrentPhase(CommandPhase) || this.isCurrentPhase(NewBattlePhase)); } + forceOpponentToSwitch() { + const originalMatchupScore = Trainer.prototype.getPartyMemberMatchupScores; + Trainer.prototype.getPartyMemberMatchupScores = () => { + Trainer.prototype.getPartyMemberMatchupScores = originalMatchupScore; + return [[1, 100], [1, 100]]; + }; + } + /** Transition to the next upcoming {@linkcode CommandPhase} */ async toNextTurn() { await this.phaseInterceptor.to(CommandPhase); @@ -281,14 +290,16 @@ export default class GameManager { }); } - async switchPokemon(pokemonIndex: number) { + async switchPokemon(pokemonIndex: number, toNext: boolean = true) { this.onNextPrompt("CommandPhase", Mode.COMMAND, () => { this.scene.ui.setMode(Mode.PARTY, PartyUiMode.SWITCH, (this.scene.getCurrentPhase() as CommandPhase).getPokemon().getFieldIndex(), null, PartyUiHandler.FilterNonFainted); }); this.onNextPrompt("CommandPhase", Mode.PARTY, () => { (this.scene.getCurrentPhase() as CommandPhase).handleCommand(Command.POKEMON, pokemonIndex, false); }); - await this.phaseInterceptor.run(CommandPhase); - await this.phaseInterceptor.to(CommandPhase); + if (toNext) { + await this.phaseInterceptor.run(CommandPhase); + await this.phaseInterceptor.to(CommandPhase); + } } }