From 00ab9eb22567890a19699be0fb99f94eaa380651 Mon Sep 17 00:00:00 2001 From: Jimmybald1 <147992650+IBBCalc@users.noreply.github.com> Date: Sat, 24 May 2025 09:58:18 +0200 Subject: [PATCH 01/10] Protect rng now resets on new waves and fixed to look at all turns in the same wave. --- src/data/moves/move.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 31ad3337926..85fb7b8da46 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -5780,20 +5780,35 @@ export class ProtectAttr extends AddBattlerTagAttr { getCondition(): MoveConditionFunc { return ((user, target, move): boolean => { + // Protect rng resets on new waves, it always succeeds. + if (user.tempSummonData.waveTurnCount === 1) { + return true; + } + let timesUsed = 0; - const moveHistory = user.getLastXMoves(); + const moveHistory = user.getLastXMoves(-1); let turnMove: TurnMove | undefined; while (moveHistory.length) { turnMove = moveHistory.shift(); + if (!allMoves[turnMove?.move ?? Moves.NONE].hasAttr(ProtectAttr) || turnMove?.result !== MoveResult.SUCCESS) { break; } + timesUsed++; + + // Break after first move used this wave. + // If no move was used on turn 1, then it would have broken in the attr check already. + if (turnMove?.turn === 1) { + break; + } } + if (timesUsed) { return !user.randBattleSeedInt(Math.pow(3, timesUsed)); } + return true; }); } From 83f40d0c51678bbc8bf70b4c52ca007240a26f69 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sun, 25 May 2025 15:37:30 -0400 Subject: [PATCH 02/10] Added per-wave move history object to fix issues @Jimmybald1 I added a commented out `console.log` in the protect code (L5797) for you to use for testing --- src/data/moves/move.ts | 32 +++++++++----------------------- src/field/pokemon.ts | 15 +++++++++++++-- src/phases/battle-end-phase.ts | 6 ------ 3 files changed, 22 insertions(+), 31 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 85fb7b8da46..23e10a248b7 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -5780,36 +5780,22 @@ export class ProtectAttr extends AddBattlerTagAttr { getCondition(): MoveConditionFunc { return ((user, target, move): boolean => { - // Protect rng resets on new waves, it always succeeds. - if (user.tempSummonData.waveTurnCount === 1) { - return true; - } - let timesUsed = 0; - const moveHistory = user.getLastXMoves(-1); - let turnMove: TurnMove | undefined; - while (moveHistory.length) { - turnMove = moveHistory.shift(); - - if (!allMoves[turnMove?.move ?? Moves.NONE].hasAttr(ProtectAttr) || turnMove?.result !== MoveResult.SUCCESS) { + for (const turnMove of user.tempSummonData.waveMoveHistory) { + if ( + // Quick & Wide guard increment the Protect counter without using it for fail chance + !(allMoves[turnMove.move].hasAttr(ProtectAttr) || [Moves.QUICK_GUARD, Moves.WIDE_GUARD].includes(turnMove.move)) + || turnMove?.result !== MoveResult.SUCCESS + ) { break; } - timesUsed++; - - // Break after first move used this wave. - // If no move was used on turn 1, then it would have broken in the attr check already. - if (turnMove?.turn === 1) { - break; - } + timesUsed++ } - if (timesUsed) { - return !user.randBattleSeedInt(Math.pow(3, timesUsed)); - } - - return true; + // console.log(`Wave Move History: ${user.tempSummonData.waveMoveHistory}\nTimes Used In Row: ${timesUsed}\nSuccess chance: 1 in ${Math.pow(3, timesUsed)}`) + return timesUsed === 0 || user.randBattleSeedInt(Math.pow(3, timesUsed)) === 0; }); } } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 62ec8081c5d..bd9056dee41 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -5122,6 +5122,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } turnMove.turn = globalScene.currentBattle?.turn; this.getMoveHistory().push(turnMove); + this.tempSummonData.waveMoveHistory.push(turnMove) } /** @@ -5774,6 +5775,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { */ resetWaveData(): void { this.waveData = new PokemonWaveData(); + this.tempSummonData.waveTurnCount = 1; + this.tempSummonData.waveMoveHistory = []; } resetTera(): void { @@ -7883,16 +7886,24 @@ export class PokemonTempSummonData { */ turnCount: number = 1; - /** + /** * The number of turns this pokemon has spent in the active position since the start of the wave * without switching out. * Reset on switch and new wave, but not stored in `SummonData` to avoid being written to the save file. - + * * Used to evaluate "first turn only" conditions such as * {@linkcode Moves.FAKE_OUT | Fake Out} and {@linkcode Moves.FIRST_IMPRESSION | First Impression}). */ waveTurnCount = 1; + /** + * An array containing all moves this Pokemon has used since the start of the wave + * without switching out. + * Reset on switch and new wave, but not stored in `SummonData` to avoid being written to the save file. + + * Used to calculate {@link https://bulbapedia.bulbagarden.net/wiki/Protection | Protecting moves}' fail chances. + */ + waveMoveHistory: TurnMove[] = []; } /** diff --git a/src/phases/battle-end-phase.ts b/src/phases/battle-end-phase.ts index b4bb28fe55e..c49f02dea9a 100644 --- a/src/phases/battle-end-phase.ts +++ b/src/phases/battle-end-phase.ts @@ -58,12 +58,6 @@ export class BattleEndPhase extends BattlePhase { globalScene.unshiftPhase(new GameOverPhase(true)); } - for (const pokemon of globalScene.getField()) { - if (pokemon) { - pokemon.tempSummonData.waveTurnCount = 1; - } - } - for (const pokemon of globalScene.getPokemonAllowedInBattle()) { applyPostBattleAbAttrs(PostBattleAbAttr, pokemon, false, this.isVictory); } From 2af15fe9fced3db85f11cdc4b4f122f04d0c2c37 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sun, 25 May 2025 15:37:42 -0400 Subject: [PATCH 03/10] Added many tests --- test/moves/baneful_bunker.test.ts | 66 +++++----- test/moves/crafty_shield.test.ts | 138 +++++++++++--------- test/moves/endure.test.ts | 43 ++++--- test/moves/protect.test.ts | 206 +++++++++++++++++++++++------- test/moves/quick_guard.test.ts | 99 +++++++------- test/moves/wide_guard.test.ts | 123 +++++++++--------- 6 files changed, 396 insertions(+), 279 deletions(-) diff --git a/test/moves/baneful_bunker.test.ts b/test/moves/baneful_bunker.test.ts index 4d0d7237c00..cb9df89e1b1 100644 --- a/test/moves/baneful_bunker.test.ts +++ b/test/moves/baneful_bunker.test.ts @@ -1,5 +1,5 @@ import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import GameManager from "#test/testUtils/gameManager"; import { Species } from "#enums/species"; import { Abilities } from "#enums/abilities"; @@ -24,59 +24,53 @@ describe("Moves - Baneful Bunker", () => { beforeEach(() => { game = new GameManager(phaserGame); - game.override.battleStyle("single"); - - game.override.moveset(Moves.SLASH); - - game.override.enemySpecies(Species.SNORLAX); - game.override.enemyAbility(Abilities.INSOMNIA); - game.override.enemyMoveset(Moves.BANEFUL_BUNKER); - - game.override.startingLevel(100); - game.override.enemyLevel(100); + game.override + .battleStyle("single") + .moveset([Moves.SLASH, Moves.FLASH_CANNON]) + .enemySpecies(Species.TOXAPEX) + .enemyAbility(Abilities.INSOMNIA) + .enemyMoveset(Moves.BANEFUL_BUNKER) + .startingLevel(100) + .enemyLevel(100); }); - test("should protect the user and poison attackers that make contact", async () => { - await game.classicMode.startBattle([Species.CHARIZARD]); - const leadPokemon = game.scene.getPlayerPokemon()!; - const enemyPokemon = game.scene.getEnemyPokemon()!; + function expectProtected() { + expect(game.scene.getEnemyPokemon()?.hp).toBe(game.scene.getEnemyPokemon()?.getMaxHp()); + expect(game.scene.getPlayerPokemon()?.status?.effect).toBe(StatusEffect.POISON); + } + + it("should protect the user and poison attackers that make contact", async () => { + await game.classicMode.startBattle([Species.CHARIZARD]); game.move.select(Moves.SLASH); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.phaseInterceptor.to("BerryPhase", false); - expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); - expect(leadPokemon.status?.effect === StatusEffect.POISON).toBeTruthy(); + + expectProtected(); }); - test("should protect the user and poison attackers that make contact, regardless of accuracy checks", async () => { + + it("should ignore accuracy checks", async () => { await game.classicMode.startBattle([Species.CHARIZARD]); - const leadPokemon = game.scene.getPlayerPokemon()!; - const enemyPokemon = game.scene.getEnemyPokemon()!; - game.move.select(Moves.SLASH); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.phaseInterceptor.to("MoveEffectPhase"); - + await game.phaseInterceptor.to("MoveEndPhase"); // baneful bunker await game.move.forceMiss(); + await game.phaseInterceptor.to("BerryPhase", false); - expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); - expect(leadPokemon.status?.effect === StatusEffect.POISON).toBeTruthy(); + + expectProtected(); }); - test("should not poison attackers that don't make contact", async () => { - game.override.moveset(Moves.FLASH_CANNON); + it("should block non-contact moves without poisoning attackers", async () => { await game.classicMode.startBattle([Species.CHARIZARD]); - const leadPokemon = game.scene.getPlayerPokemon()!; - const enemyPokemon = game.scene.getEnemyPokemon()!; + const charizard = game.scene.getPlayerPokemon()!; + const toxapex = game.scene.getEnemyPokemon()!; game.move.select(Moves.FLASH_CANNON); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.phaseInterceptor.to("MoveEffectPhase"); - - await game.move.forceMiss(); await game.phaseInterceptor.to("BerryPhase", false); - expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); - expect(leadPokemon.status?.effect === StatusEffect.POISON).toBeFalsy(); + + expect(toxapex.hp).toBe(toxapex.getMaxHp()); + expect(charizard.status?.effect).toBeUndefined(); }); }); diff --git a/test/moves/crafty_shield.test.ts b/test/moves/crafty_shield.test.ts index c61e6d3848a..8e8f16dac73 100644 --- a/test/moves/crafty_shield.test.ts +++ b/test/moves/crafty_shield.test.ts @@ -1,13 +1,14 @@ import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import GameManager from "#test/testUtils/gameManager"; import { Species } from "#enums/species"; import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; import { Stat } from "#enums/stat"; import { BattlerTagType } from "#app/enums/battler-tag-type"; -import { BerryPhase } from "#app/phases/berry-phase"; -import { CommandPhase } from "#app/phases/command-phase"; +import { BattlerIndex } from "#app/battle"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import { ArenaTagSide } from "#app/data/arena-tag"; describe("Moves - Crafty Shield", () => { let phaserGame: Phaser.Game; @@ -26,85 +27,102 @@ describe("Moves - Crafty Shield", () => { beforeEach(() => { game = new GameManager(phaserGame); - game.override.battleStyle("double"); - - game.override.moveset([Moves.CRAFTY_SHIELD, Moves.SPLASH, Moves.SWORDS_DANCE]); - - game.override.enemySpecies(Species.SNORLAX); - game.override.enemyMoveset([Moves.GROWL]); - game.override.enemyAbility(Abilities.INSOMNIA); - - game.override.startingLevel(100); - game.override.enemyLevel(100); + game.override + .battleStyle("double") + .moveset([Moves.CRAFTY_SHIELD, Moves.SPLASH, Moves.SWORDS_DANCE, Moves.HOWL]) + .enemySpecies(Species.DUSKNOIR) + .enemyMoveset(Moves.GROWL) + .enemyAbility(Abilities.INSOMNIA) + .startingLevel(100) + .enemyLevel(100); }); - test("should protect the user and allies from status moves", async () => { - await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + it("should protect the user and allies from status moves", async () => { + await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]); - const leadPokemon = game.scene.getPlayerField(); + const [charizard, blastoise] = game.scene.getPlayerField(); + game.move.select(Moves.CRAFTY_SHIELD, BattlerIndex.PLAYER); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.forceEnemyMove(Moves.GROWL); + await game.forceEnemyMove(Moves.GROWL); - game.move.select(Moves.CRAFTY_SHIELD); + await game.phaseInterceptor.to("TurnEndPhase"); - await game.phaseInterceptor.to(CommandPhase); - - game.move.select(Moves.SPLASH, 1); - - await game.phaseInterceptor.to(BerryPhase, false); - - leadPokemon.forEach(p => expect(p.getStatStage(Stat.ATK)).toBe(0)); + expect(charizard.getStatStage(Stat.ATK)).toBe(0); + expect(blastoise.getStatStage(Stat.ATK)).toBe(0); }); - test("should not protect the user and allies from attack moves", async () => { - game.override.enemyMoveset([Moves.TACKLE]); + it("should not protect the user and allies from attack moves", async () => { + game.override.enemyMoveset(Moves.TACKLE); + await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]); - await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + const [charizard, blastoise] = game.scene.getPlayerField(); - const leadPokemon = game.scene.getPlayerField(); + game.move.select(Moves.CRAFTY_SHIELD, BattlerIndex.PLAYER); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER_2); + await game.phaseInterceptor.to("TurnEndPhase"); - game.move.select(Moves.CRAFTY_SHIELD); - - await game.phaseInterceptor.to(CommandPhase); - - game.move.select(Moves.SPLASH, 1); - - await game.phaseInterceptor.to(BerryPhase, false); - - expect(leadPokemon.some(p => p.hp < p.getMaxHp())).toBeTruthy(); + expect(charizard.isFullHp()).toBe(false); + expect(blastoise.isFullHp()).toBe(false); }); - test("should protect the user and allies from moves that ignore other protection", async () => { - game.override.enemySpecies(Species.DUSCLOPS); - game.override.enemyMoveset([Moves.CURSE]); + it("should not block entry hazards and field-targeted moves", async () => { + game.override.enemyMoveset([Moves.PERISH_SONG, Moves.TOXIC_SPIKES]); + await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]); - await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + const [charizard, blastoise] = game.scene.getPlayerField(); - const leadPokemon = game.scene.getPlayerField(); + game.move.select(Moves.CRAFTY_SHIELD, BattlerIndex.PLAYER); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.forceEnemyMove(Moves.PERISH_SONG); + await game.forceEnemyMove(Moves.TOXIC_SPIKES); + await game.phaseInterceptor.to("TurnEndPhase"); - game.move.select(Moves.CRAFTY_SHIELD); - - await game.phaseInterceptor.to(CommandPhase); - - game.move.select(Moves.SPLASH, 1); - - await game.phaseInterceptor.to(BerryPhase, false); - - leadPokemon.forEach(p => expect(p.getTag(BattlerTagType.CURSED)).toBeUndefined()); + expect(game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.PLAYER)).toBeDefined(); + expect(charizard.getTag(BattlerTagType.PERISH_SONG)).toBeDefined(); + expect(blastoise.getTag(BattlerTagType.PERISH_SONG)).toBeDefined(); }); - test("should not block allies' self-targeted moves", async () => { - await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + it("should protect the user and allies from moves that ignore other protection", async () => { + game.override.moveset(Moves.CURSE); + await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]); - const leadPokemon = game.scene.getPlayerField(); + const [charizard, blastoise] = game.scene.getPlayerField(); - game.move.select(Moves.CRAFTY_SHIELD); + game.move.select(Moves.CRAFTY_SHIELD, BattlerIndex.PLAYER); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.forceEnemyMove(Moves.CURSE, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.CURSE, BattlerIndex.PLAYER_2); - await game.phaseInterceptor.to(CommandPhase); + await game.phaseInterceptor.to("TurnEndPhase"); - game.move.select(Moves.SWORDS_DANCE, 1); + expect(charizard.getTag(BattlerTagType.CURSED)).toBeDefined(); + expect(blastoise.getTag(BattlerTagType.CURSED)).toBeDefined(); - await game.phaseInterceptor.to(BerryPhase, false); + const [dusknoir1, dusknoir2] = game.scene.getEnemyField(); + expect(dusknoir1.isFullHp()).toBe(false); + expect(dusknoir2.isFullHp()).toBe(false); + }); - expect(leadPokemon[0].getStatStage(Stat.ATK)).toBe(0); - expect(leadPokemon[1].getStatStage(Stat.ATK)).toBe(2); + it("should not block allies' self or ally-targeted moves", async () => { + await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + + const [charizard, blastoise] = game.scene.getPlayerField(); + + game.move.select(Moves.CRAFTY_SHIELD, BattlerIndex.PLAYER); + game.move.select(Moves.SWORDS_DANCE, BattlerIndex.PLAYER_2); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(charizard.getStatStage(Stat.ATK)).toBe(0); + expect(blastoise.getStatStage(Stat.ATK)).toBe(2); + + game.move.select(Moves.HOWL, BattlerIndex.PLAYER); + game.move.select(Moves.CRAFTY_SHIELD, BattlerIndex.PLAYER_2); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(charizard.getStatStage(Stat.ATK)).toBe(1); + expect(blastoise.getStatStage(Stat.ATK)).toBe(3); }); }); diff --git a/test/moves/endure.test.ts b/test/moves/endure.test.ts index 190a689f46e..205e2721a5a 100644 --- a/test/moves/endure.test.ts +++ b/test/moves/endure.test.ts @@ -1,9 +1,10 @@ +import { HitResult } from "#app/field/pokemon"; import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; describe("Moves - Endure", () => { let phaserGame: Phaser.Game; @@ -22,7 +23,7 @@ describe("Moves - Endure", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset([Moves.THUNDER, Moves.BULLET_SEED, Moves.TOXIC, Moves.SHEER_COLD]) + .moveset([Moves.THUNDER, Moves.BULLET_SEED, Moves.SHEER_COLD]) .ability(Abilities.SKILL_LINK) .startingLevel(100) .battleStyle("single") @@ -32,55 +33,55 @@ describe("Moves - Endure", () => { .enemyMoveset(Moves.ENDURE); }); - it("should let the pokemon survive with 1 HP", async () => { + it("should let the pokemon survive with 1 HP from attacks", async () => { await game.classicMode.startBattle([Species.ARCEUS]); game.move.select(Moves.THUNDER); await game.phaseInterceptor.to("BerryPhase"); - expect(game.scene.getEnemyPokemon()!.hp).toBe(1); + const enemy = game.scene.getEnemyPokemon()!; + expect(enemy.hp).toBe(1); }); - it("should let the pokemon survive with 1 HP when hit with a multihit move", async () => { + it("should let the pokemon survive with 1 HP from multi-strike moves", async () => { await game.classicMode.startBattle([Species.ARCEUS]); game.move.select(Moves.BULLET_SEED); await game.phaseInterceptor.to("BerryPhase"); - expect(game.scene.getEnemyPokemon()!.hp).toBe(1); + const enemy = game.scene.getEnemyPokemon()!; + expect(enemy.hp).toBe(1); }); it("should let the pokemon survive against OHKO moves", async () => { await game.classicMode.startBattle([Species.MAGIKARP]); - const enemy = game.scene.getEnemyPokemon()!; game.move.select(Moves.SHEER_COLD); await game.phaseInterceptor.to("TurnEndPhase"); - expect(enemy.isFainted()).toBeFalsy(); + const enemy = game.scene.getEnemyPokemon()!; + expect(enemy.hp).toBe(1); }); // comprehensive indirect damage test copied from Reviver Seed test 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.TOXIC }, + { moveType: "Damaging Move Chip", move: Moves.SALT_CURE }, + { moveType: "Status Move Chip", move: Moves.LEECH_SEED }, + { moveType: "Partial Trapping move", move: Moves.WHIRLPOOL }, + { moveType: "Status Effect", move: Moves.TOXIC }, { moveType: "Weather", move: Moves.SANDSTORM }, - ])("should not prevent fainting from $moveType", async ({ move }) => { - game.override - .enemyLevel(1) - .startingLevel(100) - .enemySpecies(Species.MAGIKARP) - .moveset(move) - .enemyMoveset(Moves.ENDURE); + ])("should not prevent fainting from $moveType Damage", async ({ move }) => { + game.override.moveset(move).enemyLevel(100); await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]); + const enemy = game.scene.getEnemyPokemon()!; - enemy.damageAndUpdate(enemy.hp - 1); + enemy.hp = 2; + // force attack to do 1 dmg (for salt cure) + vi.spyOn(enemy, "getAttackDamage").mockReturnValue({ cancelled: false, result: HitResult.EFFECTIVE, damage: 1 }); game.move.select(move); await game.phaseInterceptor.to("TurnEndPhase"); - expect(enemy.isFainted()).toBeTruthy(); + expect(enemy.isFainted()).toBe(true); }); }); diff --git a/test/moves/protect.test.ts b/test/moves/protect.test.ts index 14844019b31..ec16b3ea8af 100644 --- a/test/moves/protect.test.ts +++ b/test/moves/protect.test.ts @@ -1,14 +1,13 @@ import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import GameManager from "#test/testUtils/gameManager"; import { Species } from "#enums/species"; import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; import { Stat } from "#enums/stat"; -import { allMoves } from "#app/data/data-lists"; -import { ArenaTagSide, ArenaTrapTag } from "#app/data/arena-tag"; -import { BattlerIndex } from "#app/battle"; import { MoveResult } from "#app/field/pokemon"; +import { BattlerIndex } from "#app/battle"; +import { allMoves } from "#app/data/data-lists"; describe("Moves - Protect", () => { let phaserGame: Phaser.Game; @@ -26,92 +25,205 @@ describe("Moves - Protect", () => { beforeEach(() => { game = new GameManager(phaserGame); - - game.override.battleStyle("single"); - - game.override.moveset([Moves.PROTECT]); - game.override.enemySpecies(Species.SNORLAX); - - game.override.enemyAbility(Abilities.INSOMNIA); - game.override.enemyMoveset([Moves.TACKLE]); - - game.override.startingLevel(100); - game.override.enemyLevel(100); + game.override + .battleStyle("single") + .moveset([Moves.PROTECT, Moves.SPIKY_SHIELD, Moves.ENDURE, Moves.SPLASH]) + .enemySpecies(Species.SNORLAX) + .enemyAbility(Abilities.INSOMNIA) + .enemyMoveset(Moves.LUMINA_CRASH) + .startingLevel(100) + .enemyLevel(100); }); - test("should protect the user from attacks", async () => { + it("should protect the user from attacks and their secondary effects", async () => { await game.classicMode.startBattle([Species.CHARIZARD]); - const leadPokemon = game.scene.getPlayerPokemon()!; + const charizard = game.scene.getPlayerPokemon()!; game.move.select(Moves.PROTECT); - await game.phaseInterceptor.to("BerryPhase", false); - expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp()); + expect(charizard.hp).toBe(charizard.getMaxHp()); + expect(charizard.getStatStage(Stat.SPDEF)).toBe(0); + expect(charizard); }); - test("should prevent secondary effects from the opponent's attack", async () => { - game.override.enemyMoveset([Moves.CEASELESS_EDGE]); - vi.spyOn(allMoves[Moves.CEASELESS_EDGE], "accuracy", "get").mockReturnValue(100); - + it.each<{ numTurns: number; chance: number }>([ + { numTurns: 1, chance: 3 }, + { numTurns: 2, chance: 9 }, + { numTurns: 3, chance: 27 }, + { numTurns: 4, chance: 81 }, + ])("should have a 1/$chance success rate after $times successful uses", async ({ numTurns, chance }) => { await game.classicMode.startBattle([Species.CHARIZARD]); - const leadPokemon = game.scene.getPlayerPokemon()!; + const charizard = game.scene.getPlayerPokemon()!; + + // mock RNG roll to suceed unless exactly the desired chance is hit + vi.spyOn(charizard, "randBattleSeedInt").mockImplementation(range => (range !== chance ? 0 : 1)); + const conditionSpy = vi.spyOn(allMoves[Moves.PROTECT]["conditions"][0], "apply"); + + // click protect many times + for (let x = 0; x < numTurns; x++) { + game.move.select(Moves.PROTECT); + await game.toNextTurn(); + + expect(charizard.hp).toBe(charizard.getMaxHp()); + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + expect(conditionSpy).toHaveLastReturnedWith(true); + } game.move.select(Moves.PROTECT); + await game.toNextTurn(); - await game.phaseInterceptor.to("BerryPhase", false); - - expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp()); - expect(game.scene.arena.getTagOnSide(ArenaTrapTag, ArenaTagSide.ENEMY)).toBeUndefined(); + expect(charizard.hp).toBeLessThan(charizard.getMaxHp()); + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(conditionSpy).toHaveLastReturnedWith(false); }); - test("should protect the user from status moves", async () => { - game.override.enemyMoveset([Moves.CHARM]); - + it("should share fail chance with all move variants", async () => { await game.classicMode.startBattle([Species.CHARIZARD]); - const leadPokemon = game.scene.getPlayerPokemon()!; + const charizard = game.scene.getPlayerPokemon()!; + charizard.tempSummonData.waveMoveHistory = [ + { move: Moves.ENDURE, result: MoveResult.SUCCESS, targets: [BattlerIndex.PLAYER] }, + { move: Moves.SPIKY_SHIELD, result: MoveResult.SUCCESS, targets: [BattlerIndex.PLAYER] }, + ]; + // force protect to fail on anything >=2 uses (1/9 chance) + vi.spyOn(charizard, "randBattleSeedInt").mockImplementation(range => (range >= 9 ? 1 : 0)); game.move.select(Moves.PROTECT); + await game.toNextTurn(); - await game.phaseInterceptor.to("BerryPhase", false); - - expect(leadPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.FAIL); }); - test("should stop subsequent hits of a multi-hit move", async () => { + it("should reset fail chance on move failure", async () => { + await game.classicMode.startBattle([Species.CHARIZARD]); + + const charizard = game.scene.getPlayerPokemon()!; + // force protect to always fail if RNG roll attempt is made + vi.spyOn(charizard, "randBattleSeedInt").mockReturnValue(1); + + game.move.select(Moves.PROTECT); + await game.toNextTurn(); + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + + game.move.select(Moves.SPIKY_SHIELD); + await game.toNextTurn(); + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + + game.move.select(Moves.SPIKY_SHIELD); + await game.toNextTurn(); + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + }); + + it("should reset fail chance on using another move", async () => { + await game.classicMode.startBattle([Species.CHARIZARD]); + + const charizard = game.scene.getPlayerPokemon()!; + // force protect to always fail if RNG roll attempt is made + vi.spyOn(charizard, "randBattleSeedInt").mockReturnValue(1); + + game.move.select(Moves.PROTECT); + await game.toNextTurn(); + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + + game.move.select(Moves.SPLASH); + await game.toNextTurn(); + + game.move.select(Moves.PROTECT); + await game.toNextTurn(); + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + }); + + it("should reset fail chance on starting a new wave", async () => { + await game.classicMode.startBattle([Species.CHARIZARD]); + + const charizard = game.scene.getPlayerPokemon()!; + // force protect to always fail if RNG roll attempt is made + vi.spyOn(charizard, "randBattleSeedInt").mockReturnValue(1); + + game.move.select(Moves.PROTECT); + // Wait until move end phase to kill opponent to ensure protect doesn't fail due to going last + await game.phaseInterceptor.to("MoveEndPhase"); + await game.doKillOpponents(); + await game.toNextWave(); + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + + game.move.select(Moves.SPIKY_SHIELD); + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + }); + + it("should not be blocked by Psychic Terrain", async () => { + game.override.ability(Abilities.PSYCHIC_SURGE); + await game.classicMode.startBattle([Species.CHARIZARD]); + + const charizard = game.scene.getPlayerPokemon()!; + game.move.select(Moves.PROTECT); + await game.toNextTurn(); + + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + }); + + it("should stop subsequent hits of multi-hit moves", async () => { game.override.enemyMoveset([Moves.TACHYON_CUTTER]); - await game.classicMode.startBattle([Species.CHARIZARD]); - const leadPokemon = game.scene.getPlayerPokemon()!; + const charizard = game.scene.getPlayerPokemon()!; const enemyPokemon = game.scene.getEnemyPokemon()!; game.move.select(Moves.PROTECT); - await game.phaseInterceptor.to("BerryPhase", false); - expect(leadPokemon.hp).toBe(leadPokemon.getMaxHp()); + expect(charizard.hp).toBe(charizard.getMaxHp()); expect(enemyPokemon.turnData.hitCount).toBe(1); }); - test("should fail if the user is the last to move in the turn", async () => { - game.override.enemyMoveset([Moves.PROTECT]); - + it("should fail if the user moves last in the turn", async () => { + game.override.enemyMoveset(Moves.PROTECT); await game.classicMode.startBattle([Species.CHARIZARD]); - const leadPokemon = game.scene.getPlayerPokemon()!; + const charizard = game.scene.getPlayerPokemon()!; const enemyPokemon = game.scene.getEnemyPokemon()!; game.move.select(Moves.PROTECT); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); - await game.phaseInterceptor.to("BerryPhase", false); expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); - expect(leadPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.FAIL); }); + + it("should not block Protection-bypassing moves or Future Sight", async () => { + game.override.enemyMoveset([Moves.FUTURE_SIGHT, Moves.MIGHTY_CLEAVE, Moves.SPORE]); + await game.classicMode.startBattle([Species.AGGRON]); + + const aggron = game.scene.getPlayerPokemon()!; + vi.spyOn(aggron, "randBattleSeedInt").mockReturnValue(0); + + // Turn 1: setup future sight + game.move.select(Moves.PROTECT); + await game.forceEnemyMove(Moves.FUTURE_SIGHT); + await game.toNextTurn(); + + // Turn 2: mighty cleave + game.move.select(Moves.PROTECT); + await game.forceEnemyMove(Moves.MIGHTY_CLEAVE); + await game.toNextTurn(); + + expect(aggron.hp).toBeLessThan(aggron.getMaxHp()); + + aggron.hp = aggron.getMaxHp(); + + // turn 3: Future Sight hits + game.move.select(Moves.PROTECT); + await game.forceEnemyMove(Moves.SPORE); + await game.toNextTurn(); + + expect(aggron.hp).toBeLessThan(aggron.getMaxHp()); + expect(aggron.status?.effect).toBeUndefined(); // check that protect actually worked + }); + + // TODO: Add test + it.todo("should not reset counter when throwing balls"); }); diff --git a/test/moves/quick_guard.test.ts b/test/moves/quick_guard.test.ts index d9970ce64fa..d95a17788fe 100644 --- a/test/moves/quick_guard.test.ts +++ b/test/moves/quick_guard.test.ts @@ -1,10 +1,9 @@ import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import GameManager from "#test/testUtils/gameManager"; import { Species } from "#enums/species"; import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; -import { Stat } from "#enums/stat"; import { BattlerIndex } from "#app/battle"; import { MoveResult } from "#app/field/pokemon"; @@ -25,80 +24,74 @@ describe("Moves - Quick Guard", () => { beforeEach(() => { game = new GameManager(phaserGame); - game.override.battleStyle("double"); - - game.override.moveset([Moves.QUICK_GUARD, Moves.SPLASH, Moves.FOLLOW_ME]); - - game.override.enemySpecies(Species.SNORLAX); - game.override.enemyMoveset([Moves.QUICK_ATTACK]); - game.override.enemyAbility(Abilities.INSOMNIA); - - game.override.startingLevel(100); - game.override.enemyLevel(100); + game.override + .battleStyle("double") + .moveset([Moves.QUICK_GUARD, Moves.SPLASH, Moves.SPIKY_SHIELD]) + .enemySpecies(Species.SNORLAX) + .enemyMoveset(Moves.QUICK_ATTACK) + .enemyAbility(Abilities.BALL_FETCH) + .startingLevel(100) + .enemyLevel(100); }); - test("should protect the user and allies from priority moves", async () => { + it("should protect the user and allies from priority moves", async () => { await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]); - const playerPokemon = game.scene.getPlayerField(); - - game.move.select(Moves.QUICK_GUARD); - game.move.select(Moves.SPLASH, 1); + const [charizard, blastoise] = game.scene.getPlayerField(); + game.move.select(Moves.QUICK_GUARD, BattlerIndex.PLAYER); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.forceEnemyMove(Moves.QUICK_ATTACK, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.QUICK_ATTACK, BattlerIndex.PLAYER_2); await game.phaseInterceptor.to("BerryPhase", false); - playerPokemon.forEach(p => expect(p.hp).toBe(p.getMaxHp())); + expect(charizard.hp).toBe(charizard.getMaxHp()); + expect(blastoise.hp).toBe(blastoise.getMaxHp()); }); - test("should protect the user and allies from Prankster-boosted moves", async () => { - game.override.enemyAbility(Abilities.PRANKSTER); - game.override.enemyMoveset([Moves.GROWL]); - + it.each<{ name: string; move: Moves; ability: Abilities }>([ + { name: "Prankster", move: Moves.SPORE, ability: Abilities.PRANKSTER }, + { name: "Gale Wings", move: Moves.BRAVE_BIRD, ability: Abilities.GALE_WINGS }, + ])("should protect the user and allies from $name-boosted moves", async ({ move, ability }) => { + game.override.enemyMoveset(move).enemyAbility(ability); await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]); - const playerPokemon = game.scene.getPlayerField(); - - game.move.select(Moves.QUICK_GUARD); - game.move.select(Moves.SPLASH, 1); + const [charizard, blastoise] = game.scene.getPlayerField(); + game.move.select(Moves.QUICK_GUARD, BattlerIndex.PLAYER); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.forceEnemyMove(move, BattlerIndex.PLAYER); + await game.forceEnemyMove(move, BattlerIndex.PLAYER_2); await game.phaseInterceptor.to("BerryPhase", false); - playerPokemon.forEach(p => expect(p.getStatStage(Stat.ATK)).toBe(0)); + expect(charizard.hp).toBe(charizard.getMaxHp()); + expect(blastoise.hp).toBe(blastoise.getMaxHp()); + expect(charizard.status?.effect).toBeUndefined(); + expect(blastoise.status?.effect).toBeUndefined(); }); - test("should stop subsequent hits of a multi-hit priority move", async () => { - game.override.enemyMoveset([Moves.WATER_SHURIKEN]); - - await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]); - - const playerPokemon = game.scene.getPlayerField(); - const enemyPokemon = game.scene.getEnemyField(); - - game.move.select(Moves.QUICK_GUARD); - game.move.select(Moves.FOLLOW_ME, 1); - - await game.phaseInterceptor.to("BerryPhase", false); - - playerPokemon.forEach(p => expect(p.hp).toBe(p.getMaxHp())); - enemyPokemon.forEach(p => expect(p.turnData.hitCount).toBe(1)); - }); - - test("should fail if the user is the last to move in the turn", async () => { + it("should increment (but not respect) other protection moves' fail counters", async () => { game.override.battleStyle("single"); - game.override.enemyMoveset([Moves.QUICK_GUARD]); - await game.classicMode.startBattle([Species.CHARIZARD]); - const playerPokemon = game.scene.getPlayerPokemon()!; - const enemyPokemon = game.scene.getEnemyPokemon()!; + const charizard = game.scene.getPlayerPokemon()!; + // force protect to fail on anything >0 uses + vi.spyOn(charizard, "randBattleSeedInt").mockReturnValue(1); game.move.select(Moves.QUICK_GUARD); + await game.toNextTurn(); - await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); - await game.phaseInterceptor.to("BerryPhase", false); + game.move.select(Moves.QUICK_GUARD); + await game.toNextTurn(); - expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); - expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + // ignored fail chance + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); + + game.move.select(Moves.SPIKY_SHIELD); + await game.toNextTurn(); + + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.FAIL); }); }); diff --git a/test/moves/wide_guard.test.ts b/test/moves/wide_guard.test.ts index 85ebad806d7..1c31470d9d0 100644 --- a/test/moves/wide_guard.test.ts +++ b/test/moves/wide_guard.test.ts @@ -1,12 +1,12 @@ import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import GameManager from "#test/testUtils/gameManager"; import { Species } from "#enums/species"; import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; import { Stat } from "#enums/stat"; -import { BerryPhase } from "#app/phases/berry-phase"; -import { CommandPhase } from "#app/phases/command-phase"; +import { BattlerIndex } from "#app/battle"; +import { MoveResult } from "#app/field/pokemon"; describe("Moves - Wide Guard", () => { let phaserGame: Phaser.Game; @@ -25,87 +25,86 @@ describe("Moves - Wide Guard", () => { beforeEach(() => { game = new GameManager(phaserGame); - game.override.battleStyle("double"); - - game.override.moveset([Moves.WIDE_GUARD, Moves.SPLASH, Moves.SURF]); - - game.override.enemySpecies(Species.SNORLAX); - game.override.enemyMoveset([Moves.SWIFT]); - game.override.enemyAbility(Abilities.INSOMNIA); - - game.override.startingLevel(100); - game.override.enemyLevel(100); + game.override + .battleStyle("double") + .moveset([Moves.WIDE_GUARD, Moves.SPLASH, Moves.SURF, Moves.SPIKY_SHIELD]) + .enemySpecies(Species.SNORLAX) + .enemyMoveset([Moves.SWIFT, Moves.GROWL, Moves.TACKLE]) + .enemyAbility(Abilities.INSOMNIA) + .startingLevel(100) + .enemyLevel(100); }); - test("should protect the user and allies from multi-target attack moves", async () => { - await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + it("should protect the user and allies from multi-target attack and status moves", async () => { + await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + const [charizard, blastoise] = game.scene.getPlayerField(); - const leadPokemon = game.scene.getPlayerField(); + game.move.select(Moves.WIDE_GUARD, BattlerIndex.PLAYER); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.forceEnemyMove(Moves.SWIFT); + await game.forceEnemyMove(Moves.GROWL); + await game.phaseInterceptor.to("TurnEndPhase"); - game.move.select(Moves.WIDE_GUARD); - - await game.phaseInterceptor.to(CommandPhase); - - game.move.select(Moves.SPLASH, 1); - - await game.phaseInterceptor.to(BerryPhase, false); - - leadPokemon.forEach(p => expect(p.hp).toBe(p.getMaxHp())); + expect(charizard.hp).toBe(charizard.getMaxHp()); + expect(blastoise.hp).toBe(blastoise.getMaxHp()); + expect(charizard.getStatStage(Stat.ATK)).toBe(0); + expect(blastoise.getStatStage(Stat.ATK)).toBe(0); }); - test("should protect the user and allies from multi-target status moves", async () => { - game.override.enemyMoveset([Moves.GROWL]); + it("should not protect the user and allies from single-target moves", async () => { + await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]); - await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + const [charizard, blastoise] = game.scene.getPlayerField(); + game.move.select(Moves.WIDE_GUARD, BattlerIndex.PLAYER); + game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER_2); + await game.phaseInterceptor.to("TurnEndPhase"); - const leadPokemon = game.scene.getPlayerField(); - - game.move.select(Moves.WIDE_GUARD); - - await game.phaseInterceptor.to(CommandPhase); - - game.move.select(Moves.SPLASH, 1); - - await game.phaseInterceptor.to(BerryPhase, false); - - leadPokemon.forEach(p => expect(p.getStatStage(Stat.ATK)).toBe(0)); + expect(charizard.hp).toBeLessThan(charizard.getMaxHp()); + expect(blastoise.hp).toBeLessThan(blastoise.getMaxHp()); }); - test("should not protect the user and allies from single-target moves", async () => { - game.override.enemyMoveset([Moves.TACKLE]); + it("should protect the user from its ally's multi-target move", async () => { + game.override.enemyMoveset(Moves.SPLASH); - await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); + await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]); - const leadPokemon = game.scene.getPlayerField(); + const charizard = game.scene.getPlayerPokemon()!; + const [snorlax1, snorlax2] = game.scene.getEnemyField(); - game.move.select(Moves.WIDE_GUARD); + game.move.select(Moves.WIDE_GUARD, BattlerIndex.PLAYER); + game.move.select(Moves.SURF, BattlerIndex.PLAYER_2); + await game.phaseInterceptor.to("TurnEndPhase"); - await game.phaseInterceptor.to(CommandPhase); - - game.move.select(Moves.SPLASH, 1); - - await game.phaseInterceptor.to(BerryPhase, false); - - expect(leadPokemon.some(p => p.hp < p.getMaxHp())).toBeTruthy(); + expect(charizard.hp).toBe(charizard.getMaxHp()); + expect(snorlax1.hp).toBeLessThan(snorlax1.getMaxHp()); + expect(snorlax2.hp).toBeLessThan(snorlax2.getMaxHp()); }); - test("should protect the user from its ally's multi-target move", async () => { - game.override.enemyMoveset([Moves.SPLASH]); + it("should increment (but not respect) other protection moves' fail counters", async () => { + game.override.battleStyle("single"); + await game.classicMode.startBattle([Species.CHARIZARD]); - await game.startBattle([Species.CHARIZARD, Species.BLASTOISE]); - - const leadPokemon = game.scene.getPlayerField(); - const enemyPokemon = game.scene.getEnemyField(); + const charizard = game.scene.getPlayerPokemon()!; + // force protect to fail on anything other than a guaranteed success + vi.spyOn(charizard, "randBattleSeedInt").mockReturnValue(1); game.move.select(Moves.WIDE_GUARD); + await game.toNextTurn(); - await game.phaseInterceptor.to(CommandPhase); + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); - game.move.select(Moves.SURF, 1); + // ignored fail chance + game.move.select(Moves.WIDE_GUARD); + await game.toNextTurn(); - await game.phaseInterceptor.to(BerryPhase, false); + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS); - expect(leadPokemon[0].hp).toBe(leadPokemon[0].getMaxHp()); - enemyPokemon.forEach(p => expect(p.hp).toBeLessThan(p.getMaxHp())); + game.move.select(Moves.SPIKY_SHIELD); + await game.toNextTurn(); + + // ignored fail chance + expect(charizard.getLastXMoves()[0].result).toBe(MoveResult.FAIL); }); }); From 934b47c535969f5faa723f190ea26dae7a96a362 Mon Sep 17 00:00:00 2001 From: Jimmybald1 <147992650+IBBCalc@users.noreply.github.com> Date: Mon, 26 May 2025 19:50:39 +0200 Subject: [PATCH 04/10] Wave move history has to be looped in reverse --- src/data/moves/move.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 23e10a248b7..b7aa17979d9 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -5782,11 +5782,12 @@ export class ProtectAttr extends AddBattlerTagAttr { return ((user, target, move): boolean => { let timesUsed = 0; - for (const turnMove of user.tempSummonData.waveMoveHistory) { + for (const turnMove of user.tempSummonData.waveMoveHistory.reverse()) { if ( // Quick & Wide guard increment the Protect counter without using it for fail chance - !(allMoves[turnMove.move].hasAttr(ProtectAttr) || [Moves.QUICK_GUARD, Moves.WIDE_GUARD].includes(turnMove.move)) - || turnMove?.result !== MoveResult.SUCCESS + !(allMoves[turnMove.move].hasAttr(ProtectAttr) || + [Moves.QUICK_GUARD, Moves.WIDE_GUARD].includes(turnMove.move)) || + turnMove?.result !== MoveResult.SUCCESS ) { break; } @@ -5794,7 +5795,7 @@ export class ProtectAttr extends AddBattlerTagAttr { timesUsed++ } - // console.log(`Wave Move History: ${user.tempSummonData.waveMoveHistory}\nTimes Used In Row: ${timesUsed}\nSuccess chance: 1 in ${Math.pow(3, timesUsed)}`) + // console.log(`Wave Move History: ${user.tempSummonData.waveMoveHistory.reverse().map(t => t.move)}\nTimes Used In Row: ${timesUsed}\nSuccess chance: 1 in ${Math.pow(3, timesUsed)}`) return timesUsed === 0 || user.randBattleSeedInt(Math.pow(3, timesUsed)) === 0; }); } From 15c9d28a0411898bb99105c545f2797801a265de Mon Sep 17 00:00:00 2001 From: Jimmybald1 <122436263+Jimmybald1@users.noreply.github.com> Date: Tue, 27 May 2025 11:25:44 +0200 Subject: [PATCH 05/10] Update src/data/moves/move.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/data/moves/move.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index b7aa17979d9..e4d7c8e3361 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -5787,7 +5787,7 @@ export class ProtectAttr extends AddBattlerTagAttr { // Quick & Wide guard increment the Protect counter without using it for fail chance !(allMoves[turnMove.move].hasAttr(ProtectAttr) || [Moves.QUICK_GUARD, Moves.WIDE_GUARD].includes(turnMove.move)) || - turnMove?.result !== MoveResult.SUCCESS + turnMove.result !== MoveResult.SUCCESS ) { break; } From 7dfa3ef5410b3ad5c0beda582e07500007ef51de Mon Sep 17 00:00:00 2001 From: Jimmybald1 <122436263+Jimmybald1@users.noreply.github.com> Date: Tue, 27 May 2025 11:25:51 +0200 Subject: [PATCH 06/10] Update src/data/moves/move.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/data/moves/move.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index e4d7c8e3361..f83b7a7e012 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -5782,7 +5782,7 @@ export class ProtectAttr extends AddBattlerTagAttr { return ((user, target, move): boolean => { let timesUsed = 0; - for (const turnMove of user.tempSummonData.waveMoveHistory.reverse()) { + for (const turnMove of user.tempSummonData.waveMoveHistory.slice().reverse()) { if ( // Quick & Wide guard increment the Protect counter without using it for fail chance !(allMoves[turnMove.move].hasAttr(ProtectAttr) || From b5b19ce5df4c006865270d1d1db3088bfcb113fb Mon Sep 17 00:00:00 2001 From: Jimmybald1 <147992650+IBBCalc@users.noreply.github.com> Date: Tue, 27 May 2025 11:31:37 +0200 Subject: [PATCH 07/10] comments --- src/field/pokemon.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index bd9056dee41..2610311f4d2 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -7878,7 +7878,11 @@ export class PokemonSummonData { } } - // TODO: Merge this inside `summmonData` but exclude from save if/when a save data serializer is added +/** + * Pokemon data that is not stored in `SummonData` to avoid being written to the save file. + * + * TODO: Merge this inside `summmonData` but exclude from save if/when a save data serializer is added + */ export class PokemonTempSummonData { /** * The number of turns this pokemon has spent without switching out. @@ -7889,7 +7893,7 @@ export class PokemonTempSummonData { /** * The number of turns this pokemon has spent in the active position since the start of the wave * without switching out. - * Reset on switch and new wave, but not stored in `SummonData` to avoid being written to the save file. + * Reset on switch and new wave. * * Used to evaluate "first turn only" conditions such as * {@linkcode Moves.FAKE_OUT | Fake Out} and {@linkcode Moves.FIRST_IMPRESSION | First Impression}). @@ -7899,7 +7903,7 @@ export class PokemonTempSummonData { /** * An array containing all moves this Pokemon has used since the start of the wave * without switching out. - * Reset on switch and new wave, but not stored in `SummonData` to avoid being written to the save file. + * Reset on switch and new wave. * Used to calculate {@link https://bulbapedia.bulbagarden.net/wiki/Protection | Protecting moves}' fail chances. */ From 0bc9f631a2a6e52b499abd8cd57f676006ed2ecc Mon Sep 17 00:00:00 2001 From: Jimmybald1 <147992650+IBBCalc@users.noreply.github.com> Date: Mon, 2 Jun 2025 23:05:55 +0200 Subject: [PATCH 08/10] Fixed forceEnemyMove references after merge --- test/moves/crafty_shield.test.ts | 20 ++++++++++---------- test/moves/protect.test.ts | 6 +++--- test/moves/quick_guard.test.ts | 8 ++++---- test/moves/wide_guard.test.ts | 8 ++++---- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/test/moves/crafty_shield.test.ts b/test/moves/crafty_shield.test.ts index 7d954080804..a8ff07d2c32 100644 --- a/test/moves/crafty_shield.test.ts +++ b/test/moves/crafty_shield.test.ts @@ -43,8 +43,8 @@ describe("Moves - Crafty Shield", () => { const [charizard, blastoise] = game.scene.getPlayerField(); game.move.select(Moves.CRAFTY_SHIELD, BattlerIndex.PLAYER); game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); - await game.forceEnemyMove(Moves.GROWL); - await game.forceEnemyMove(Moves.GROWL); + await game.move.forceEnemyMove(Moves.GROWL); + await game.move.forceEnemyMove(Moves.GROWL); await game.phaseInterceptor.to("TurnEndPhase"); @@ -55,13 +55,13 @@ describe("Moves - Crafty Shield", () => { it("should not protect the user and allies from attack moves", async () => { game.override.enemyMoveset(Moves.TACKLE); await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]); - + const [charizard, blastoise] = game.scene.getPlayerField(); game.move.select(Moves.CRAFTY_SHIELD, BattlerIndex.PLAYER); game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); - await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER); - await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER); + await game.move.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER_2); await game.phaseInterceptor.to("TurnEndPhase"); expect(charizard.isFullHp()).toBe(false); @@ -76,8 +76,8 @@ describe("Moves - Crafty Shield", () => { game.move.select(Moves.CRAFTY_SHIELD, BattlerIndex.PLAYER); game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); - await game.forceEnemyMove(Moves.PERISH_SONG); - await game.forceEnemyMove(Moves.TOXIC_SPIKES); + await game.move.forceEnemyMove(Moves.PERISH_SONG); + await game.move.forceEnemyMove(Moves.TOXIC_SPIKES); await game.phaseInterceptor.to("TurnEndPhase"); expect(game.scene.arena.getTagOnSide(ArenaTagType.TOXIC_SPIKES, ArenaTagSide.PLAYER)).toBeDefined(); @@ -87,15 +87,15 @@ describe("Moves - Crafty Shield", () => { it("should protect the user and allies from moves that ignore other protection", async () => { game.override.moveset(Moves.CURSE); - + await game.classicMode.startBattle([Species.CHARIZARD, Species.BLASTOISE]); const [charizard, blastoise] = game.scene.getPlayerField(); game.move.select(Moves.CRAFTY_SHIELD, BattlerIndex.PLAYER); game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); - await game.forceEnemyMove(Moves.CURSE, BattlerIndex.PLAYER); - await game.forceEnemyMove(Moves.CURSE, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(Moves.CURSE, BattlerIndex.PLAYER); + await game.move.forceEnemyMove(Moves.CURSE, BattlerIndex.PLAYER_2); await game.phaseInterceptor.to("TurnEndPhase"); diff --git a/test/moves/protect.test.ts b/test/moves/protect.test.ts index ec16b3ea8af..2ab9e782919 100644 --- a/test/moves/protect.test.ts +++ b/test/moves/protect.test.ts @@ -203,12 +203,12 @@ describe("Moves - Protect", () => { // Turn 1: setup future sight game.move.select(Moves.PROTECT); - await game.forceEnemyMove(Moves.FUTURE_SIGHT); + await game.move.forceEnemyMove(Moves.FUTURE_SIGHT); await game.toNextTurn(); // Turn 2: mighty cleave game.move.select(Moves.PROTECT); - await game.forceEnemyMove(Moves.MIGHTY_CLEAVE); + await game.move.forceEnemyMove(Moves.MIGHTY_CLEAVE); await game.toNextTurn(); expect(aggron.hp).toBeLessThan(aggron.getMaxHp()); @@ -217,7 +217,7 @@ describe("Moves - Protect", () => { // turn 3: Future Sight hits game.move.select(Moves.PROTECT); - await game.forceEnemyMove(Moves.SPORE); + await game.move.forceEnemyMove(Moves.SPORE); await game.toNextTurn(); expect(aggron.hp).toBeLessThan(aggron.getMaxHp()); diff --git a/test/moves/quick_guard.test.ts b/test/moves/quick_guard.test.ts index d95a17788fe..79dfc682c4c 100644 --- a/test/moves/quick_guard.test.ts +++ b/test/moves/quick_guard.test.ts @@ -41,8 +41,8 @@ describe("Moves - Quick Guard", () => { game.move.select(Moves.QUICK_GUARD, BattlerIndex.PLAYER); game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); - await game.forceEnemyMove(Moves.QUICK_ATTACK, BattlerIndex.PLAYER); - await game.forceEnemyMove(Moves.QUICK_ATTACK, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(Moves.QUICK_ATTACK, BattlerIndex.PLAYER); + await game.move.forceEnemyMove(Moves.QUICK_ATTACK, BattlerIndex.PLAYER_2); await game.phaseInterceptor.to("BerryPhase", false); expect(charizard.hp).toBe(charizard.getMaxHp()); @@ -60,8 +60,8 @@ describe("Moves - Quick Guard", () => { game.move.select(Moves.QUICK_GUARD, BattlerIndex.PLAYER); game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); - await game.forceEnemyMove(move, BattlerIndex.PLAYER); - await game.forceEnemyMove(move, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(move, BattlerIndex.PLAYER); + await game.move.forceEnemyMove(move, BattlerIndex.PLAYER_2); await game.phaseInterceptor.to("BerryPhase", false); expect(charizard.hp).toBe(charizard.getMaxHp()); diff --git a/test/moves/wide_guard.test.ts b/test/moves/wide_guard.test.ts index 1c31470d9d0..4c0e6e978f9 100644 --- a/test/moves/wide_guard.test.ts +++ b/test/moves/wide_guard.test.ts @@ -41,8 +41,8 @@ describe("Moves - Wide Guard", () => { game.move.select(Moves.WIDE_GUARD, BattlerIndex.PLAYER); game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); - await game.forceEnemyMove(Moves.SWIFT); - await game.forceEnemyMove(Moves.GROWL); + await game.move.forceEnemyMove(Moves.SWIFT); + await game.move.forceEnemyMove(Moves.GROWL); await game.phaseInterceptor.to("TurnEndPhase"); expect(charizard.hp).toBe(charizard.getMaxHp()); @@ -57,8 +57,8 @@ describe("Moves - Wide Guard", () => { const [charizard, blastoise] = game.scene.getPlayerField(); game.move.select(Moves.WIDE_GUARD, BattlerIndex.PLAYER); game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2); - await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER); - await game.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER_2); + await game.move.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER); + await game.move.forceEnemyMove(Moves.TACKLE, BattlerIndex.PLAYER_2); await game.phaseInterceptor.to("TurnEndPhase"); expect(charizard.hp).toBeLessThan(charizard.getMaxHp()); From 6d27914a565998d5e36ead64c3c70aeb86936b71 Mon Sep 17 00:00:00 2001 From: Jimmybald1 <122436263+Jimmybald1@users.noreply.github.com> Date: Thu, 5 Jun 2025 08:28:38 +0200 Subject: [PATCH 09/10] Removed console log Co-authored-by: Amani H. <109637146+xsn34kzx@users.noreply.github.com> --- src/data/moves/move.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index f1c9503831e..8f558362c85 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -5795,7 +5795,6 @@ export class ProtectAttr extends AddBattlerTagAttr { timesUsed++ } - // console.log(`Wave Move History: ${user.tempSummonData.waveMoveHistory.reverse().map(t => t.move)}\nTimes Used In Row: ${timesUsed}\nSuccess chance: 1 in ${Math.pow(3, timesUsed)}`) return timesUsed === 0 || user.randBattleSeedInt(Math.pow(3, timesUsed)) === 0; }); } From 7de3c11e62cea3c3b6a0f3bf3c0cf11176edddad Mon Sep 17 00:00:00 2001 From: Jimmybald1 <122436263+Jimmybald1@users.noreply.github.com> Date: Thu, 5 Jun 2025 08:29:06 +0200 Subject: [PATCH 10/10] Fixed test message Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com> --- test/moves/protect.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/moves/protect.test.ts b/test/moves/protect.test.ts index 26e967f50ac..1ab31b67c58 100644 --- a/test/moves/protect.test.ts +++ b/test/moves/protect.test.ts @@ -53,7 +53,7 @@ describe("Moves - Protect", () => { { numTurns: 2, chance: 9 }, { numTurns: 3, chance: 27 }, { numTurns: 4, chance: 81 }, - ])("should have a 1/$chance success rate after $times successful uses", async ({ numTurns, chance }) => { + ])("should have a 1/$chance success rate after $numTurns successful uses", async ({ numTurns, chance }) => { await game.classicMode.startBattle([SpeciesId.CHARIZARD]); const charizard = game.scene.getPlayerPokemon()!;