From 7cd3217114d209a92f113ea290f83bd541c8fba5 Mon Sep 17 00:00:00 2001 From: PrabbyDD <147005742+PrabbyDD@users.noreply.github.com> Date: Sun, 9 Feb 2025 00:14:56 -0800 Subject: [PATCH 1/3] [Ability] Implement Mirror Armor (#4769) * beginnings of implementation of mirror armor * logging some new changes * fixing edge cases * adding changes for sticky web and other features of mirror armor * adding changes for sticky web and other features of mirror armor * adding more unit tests and cleaning up notes * Update src/data/ability.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Update src/data/ability.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Update src/data/ability.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Update src/data/ability.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * using arena tags source id variable * updating submodule pointer for locales * small change * Update src/data/move.ts commit Kev fix (minor flip for consistency) Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * fix import * Use global scene * Update tests --------- Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> Co-authored-by: Madmadness65 <59298170+Madmadness65@users.noreply.github.com> Co-authored-by: Madmadness65 --- src/battle.ts | 3 + src/data/ability.ts | 42 +++- src/data/arena-tag.ts | 2 +- src/phases/move-effect-phase.ts | 7 + src/phases/show-ability-phase.ts | 8 + src/phases/stat-stage-change-phase.ts | 50 +++- src/test/abilities/mirror_armor.test.ts | 315 ++++++++++++++++++++++++ 7 files changed, 421 insertions(+), 6 deletions(-) create mode 100644 src/test/abilities/mirror_armor.test.ts diff --git a/src/battle.ts b/src/battle.ts index fa333040c22..3f36865c74b 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -101,6 +101,9 @@ export default class Battle { public battleSeed: string = Utils.randomString(16, true); private battleSeedState: string | null = null; public moneyScattered: number = 0; + /** Primarily for double battles, keeps track of last enemy and player pokemon that triggered its ability or used a move */ + public lastEnemyInvolved: number; + public lastPlayerInvolved: number; public lastUsedPokeball: PokeballType | null = null; /** The number of times a Pokemon on the player's side has fainted this battle */ public playerFaints: number = 0; diff --git a/src/data/ability.ts b/src/data/ability.ts index c19b6fe9ba4..21ec5667426 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -2756,6 +2756,44 @@ export class PreStatStageChangeAbAttr extends AbAttr { } } +/** + * Reflect all {@linkcode BattleStat} reductions caused by other Pokémon's moves and Abilities. + * Currently only applies to Mirror Armor. + */ +export class ReflectStatStageChangeAbAttr extends PreStatStageChangeAbAttr { + /** {@linkcode BattleStat} to reflect */ + private reflectedStat? : BattleStat; + + /** + * Apply the {@linkcode ReflectStatStageChangeAbAttr} to an interaction + * @param _pokemon The user pokemon + * @param _passive N/A + * @param simulated `true` if the ability is being simulated by the AI + * @param stat the {@linkcode BattleStat} being affected + * @param cancelled The {@linkcode Utils.BooleanHolder} that will be set to true due to reflection + * @param args + * @returns true because it reflects any stat being lowered + */ + applyPreStatStageChange(_pokemon: Pokemon, _passive: boolean, simulated: boolean, stat: BattleStat, cancelled: Utils.BooleanHolder, args: any[]): boolean { + const attacker: Pokemon = args[0]; + const stages = args[1]; + this.reflectedStat = stat; + if (!simulated) { + globalScene.unshiftPhase(new StatStageChangePhase(attacker.getBattlerIndex(), false, [ stat ], stages, true, false, true, null, true)); + } + cancelled.value = true; + return true; + } + + getTriggerMessage(pokemon: Pokemon, abilityName: string, ..._args: any[]): string { + return i18next.t("abilityTriggers:protectStat", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + abilityName, + statName: this.reflectedStat ? i18next.t(getStatKey(this.reflectedStat)) : i18next.t("battle:stats") + }); + } +} + /** * Protect one or all {@linkcode BattleStat} from reductions caused by other Pokémon's moves and Abilities */ @@ -6065,8 +6103,8 @@ export function initAbilities() { new Ability(Abilities.PROPELLER_TAIL, 8) .attr(BlockRedirectAbAttr), new Ability(Abilities.MIRROR_ARMOR, 8) - .ignorable() - .unimplemented(), + .attr(ReflectStatStageChangeAbAttr) + .ignorable(), /** * Right now, the logic is attached to Surf and Dive moves. Ideally, the post-defend/hit should be an * ability attribute but the current implementation of move effects for BattlerTag does not support this- in the case diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 816de3e824c..2fa4593fd6c 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -910,7 +910,7 @@ class StickyWebTag extends ArenaTrapTag { if (!cancelled.value) { globalScene.queueMessage(i18next.t("arenaTag:stickyWebActivateTrap", { pokemonName: pokemon.getNameToRender() })); const stages = new NumberHolder(-1); - globalScene.unshiftPhase(new StatStageChangePhase(pokemon.getBattlerIndex(), false, [ Stat.SPD ], stages.value)); + globalScene.unshiftPhase(new StatStageChangePhase(pokemon.getBattlerIndex(), false, [ Stat.SPD ], stages.value, true, false, true, null, false, true)); return true; } } diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index fff8caf38b5..be9a36940ea 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -95,6 +95,13 @@ export class MoveEffectPhase extends PokemonPhase { return super.end(); } + /** If an enemy used this move, set this as last enemy that used move or ability */ + if (!user.isPlayer()) { + globalScene.currentBattle.lastEnemyInvolved = this.fieldIndex; + } else { + globalScene.currentBattle.lastPlayerInvolved = this.fieldIndex; + } + const isDelayedAttack = this.move.getMove().hasAttr(DelayedAttackAttr); /** If the user was somehow removed from the field and it's not a delayed attack, end this phase */ if (!user.isOnField()) { diff --git a/src/phases/show-ability-phase.ts b/src/phases/show-ability-phase.ts index a0db660ded5..d759ad833a1 100644 --- a/src/phases/show-ability-phase.ts +++ b/src/phases/show-ability-phase.ts @@ -17,6 +17,14 @@ export class ShowAbilityPhase extends PokemonPhase { const pokemon = this.getPokemon(); if (pokemon) { + + if (!pokemon.isPlayer()) { + /** If its an enemy pokemon, list it as last enemy to use ability or move */ + globalScene.currentBattle.lastEnemyInvolved = pokemon.getBattlerIndex() % 2; + } else { + globalScene.currentBattle.lastPlayerInvolved = pokemon.getBattlerIndex() % 2; + } + globalScene.abilityBar.showAbility(pokemon, this.passive); if (pokemon?.battleData) { diff --git a/src/phases/stat-stage-change-phase.ts b/src/phases/stat-stage-change-phase.ts index 359610b320c..753d1f7cede 100644 --- a/src/phases/stat-stage-change-phase.ts +++ b/src/phases/stat-stage-change-phase.ts @@ -1,7 +1,8 @@ import { globalScene } from "#app/global-scene"; import type { BattlerIndex } from "#app/battle"; -import { applyAbAttrs, applyPostStatStageChangeAbAttrs, applyPreStatStageChangeAbAttrs, PostStatStageChangeAbAttr, ProtectStatAbAttr, StatStageChangeCopyAbAttr, StatStageChangeMultiplierAbAttr } from "#app/data/ability"; +import { applyAbAttrs, applyPostStatStageChangeAbAttrs, applyPreStatStageChangeAbAttrs, PostStatStageChangeAbAttr, ProtectStatAbAttr, ReflectStatStageChangeAbAttr, StatStageChangeCopyAbAttr, StatStageChangeMultiplierAbAttr } from "#app/data/ability"; import { ArenaTagSide, MistTag } from "#app/data/arena-tag"; +import type { ArenaTag } from "#app/data/arena-tag"; import type Pokemon from "#app/field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; import { ResetNegativeStatStageModifier } from "#app/modifier/modifier"; @@ -10,6 +11,8 @@ import { NumberHolder, BooleanHolder } from "#app/utils"; import i18next from "i18next"; import { PokemonPhase } from "./pokemon-phase"; import { Stat, type BattleStat, getStatKey, getStatStageChangeDescriptionKey } from "#enums/stat"; +import { OctolockTag } from "#app/data/battler-tags"; +import { ArenaTagType } from "#app/enums/arena-tag-type"; export type StatStageChangeCallback = (target: Pokemon | null, changed: BattleStat[], relativeChanges: number[]) => void; @@ -21,9 +24,11 @@ export class StatStageChangePhase extends PokemonPhase { private ignoreAbilities: boolean; private canBeCopied: boolean; private onChange: StatStageChangeCallback | null; + private comingFromMirrorArmorUser: boolean; + private comingFromStickyWeb: boolean; - constructor(battlerIndex: BattlerIndex, selfTarget: boolean, stats: BattleStat[], stages: number, showMessage: boolean = true, ignoreAbilities: boolean = false, canBeCopied: boolean = true, onChange: StatStageChangeCallback | null = null) { + constructor(battlerIndex: BattlerIndex, selfTarget: boolean, stats: BattleStat[], stages: number, showMessage: boolean = true, ignoreAbilities: boolean = false, canBeCopied: boolean = true, onChange: StatStageChangeCallback | null = null, comingFromMirrorArmorUser: boolean = false, comingFromStickyWeb: boolean = false) { super(battlerIndex); this.selfTarget = selfTarget; @@ -33,6 +38,8 @@ export class StatStageChangePhase extends PokemonPhase { this.ignoreAbilities = ignoreAbilities; this.canBeCopied = canBeCopied; this.onChange = onChange; + this.comingFromMirrorArmorUser = comingFromMirrorArmorUser; + this.comingFromStickyWeb = comingFromStickyWeb; } start() { @@ -41,12 +48,44 @@ export class StatStageChangePhase extends PokemonPhase { if (this.stats.length > 1) { for (let i = 0; i < this.stats.length; i++) { const stat = [ this.stats[i] ]; - globalScene.unshiftPhase(new StatStageChangePhase(this.battlerIndex, this.selfTarget, stat, this.stages, this.showMessage, this.ignoreAbilities, this.canBeCopied, this.onChange)); + globalScene.unshiftPhase(new StatStageChangePhase(this.battlerIndex, this.selfTarget, stat, this.stages, this.showMessage, this.ignoreAbilities, this.canBeCopied, this.onChange, this.comingFromMirrorArmorUser)); } return this.end(); } const pokemon = this.getPokemon(); + let opponentPokemon: Pokemon | undefined; + + /** Gets the position of last enemy or player pokemon that used ability or move, primarily for double battles involving Mirror Armor */ + if (pokemon.isPlayer()) { + /** If this SSCP is not from sticky web, then we find the opponent pokemon that last did something */ + if (!this.comingFromStickyWeb) { + opponentPokemon = globalScene.getEnemyField()[globalScene.currentBattle.lastEnemyInvolved]; + } else { + /** If this SSCP is from sticky web, then check if pokemon that last sucessfully used sticky web is on field */ + const stickyTagID = globalScene.arena.findTagsOnSide( + (t: ArenaTag) => t.tagType === ArenaTagType.STICKY_WEB, + ArenaTagSide.PLAYER)[0].sourceId; + globalScene.getEnemyField().forEach((e) => { + if (e.id === stickyTagID) { + opponentPokemon = e; + } + }); + } + } else { + if (!this.comingFromStickyWeb) { + opponentPokemon = globalScene.getPlayerField()[globalScene.currentBattle.lastPlayerInvolved]; + } else { + const stickyTagID = globalScene.arena.findTagsOnSide( + (t: ArenaTag) => t.tagType === ArenaTagType.STICKY_WEB, + ArenaTagSide.ENEMY)[0].sourceId; + globalScene.getPlayerField().forEach((e) => { + if (e.id === stickyTagID) { + opponentPokemon = e; + } + }); + } + } if (!pokemon.isActive(true)) { return this.end(); @@ -70,6 +109,11 @@ export class StatStageChangePhase extends PokemonPhase { if (!cancelled.value && !this.selfTarget && stages.value < 0) { applyPreStatStageChangeAbAttrs(ProtectStatAbAttr, pokemon, stat, cancelled, simulate); + + /** Potential stat reflection due to Mirror Armor, does not apply to Octolock end of turn effect */ + if (opponentPokemon !== undefined && !pokemon.findTag(t => t instanceof OctolockTag) && !this.comingFromMirrorArmorUser) { + applyPreStatStageChangeAbAttrs(ReflectStatStageChangeAbAttr, pokemon, stat, cancelled, simulate, opponentPokemon, this.stages); + } } // If one stat stage decrease is cancelled, simulate the rest of the applications diff --git a/src/test/abilities/mirror_armor.test.ts b/src/test/abilities/mirror_armor.test.ts new file mode 100644 index 00000000000..070428a8ee7 --- /dev/null +++ b/src/test/abilities/mirror_armor.test.ts @@ -0,0 +1,315 @@ +import { Stat } from "#enums/stat"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { BattlerIndex } from "#app/battle"; + +// TODO: When Magic Bounce is implemented, make a test for its interaction with mirror guard, use screech + +describe("Ability - Mirror Armor", () => { + 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") + .enemySpecies(Species.RATTATA) + .enemyMoveset([ Moves.SPLASH, Moves.STICKY_WEB, Moves.TICKLE, Moves.OCTOLOCK ]) + .enemyAbility(Abilities.BALL_FETCH) + .startingLevel(2000) + .moveset([ Moves.SPLASH, Moves.STICKY_WEB, Moves.TICKLE, Moves.OCTOLOCK ]) + .ability(Abilities.BALL_FETCH); + }); + + it("Player side + single battle Intimidate - opponent loses stats", async () => { + game.override.ability(Abilities.MIRROR_ARMOR); + game.override.enemyAbility(Abilities.INTIMIDATE); + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + const userPokemon = game.scene.getPlayerPokemon()!; + + // Enemy has intimidate, enemy should lose -1 atk + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER); + await game.toNextTurn(); + + expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1); + expect(userPokemon.getStatStage(Stat.ATK)).toBe(0); + }); + + it("Enemy side + single battle Intimidate - player loses stats", async () => { + game.override.enemyAbility(Abilities.MIRROR_ARMOR); + game.override.ability(Abilities.INTIMIDATE); + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + const userPokemon = game.scene.getPlayerPokemon()!; + + // Enemy has intimidate, enemy should lose -1 atk + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER); + await game.toNextTurn(); + + expect(userPokemon.getStatStage(Stat.ATK)).toBe(-1); + expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(0); + }); + + it("Player side + double battle Intimidate - opponents each lose -2 atk", async () => { + game.override.battleType("double"); + game.override.ability(Abilities.MIRROR_ARMOR); + game.override.enemyAbility(Abilities.INTIMIDATE); + await game.classicMode.startBattle([ Species.BULBASAUR, Species.CHARMANDER ]); + + const [ enemy1, enemy2 ] = game.scene.getEnemyField(); + const [ player1, player2 ] = game.scene.getPlayerField(); + + // Enemy has intimidate, enemy should lose -2 atk each + game.move.select(Moves.SPLASH); + game.move.select(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.toNextTurn(); + + expect(enemy1.getStatStage(Stat.ATK)).toBe(-2); + expect(enemy2.getStatStage(Stat.ATK)).toBe(-2); + expect(player1.getStatStage(Stat.ATK)).toBe(0); + expect(player2.getStatStage(Stat.ATK)).toBe(0); + }); + + it("Enemy side + double battle Intimidate - players each lose -2 atk", async () => { + game.override.battleType("double"); + game.override.enemyAbility(Abilities.MIRROR_ARMOR); + game.override.ability(Abilities.INTIMIDATE); + await game.classicMode.startBattle([ Species.BULBASAUR, Species.CHARMANDER ]); + + const [ enemy1, enemy2 ] = game.scene.getEnemyField(); + const [ player1, player2 ] = game.scene.getPlayerField(); + + // Enemy has intimidate, enemy should lose -1 atk + game.move.select(Moves.SPLASH); + game.move.select(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.toNextTurn(); + + expect(enemy1.getStatStage(Stat.ATK)).toBe(0); + expect(enemy2.getStatStage(Stat.ATK)).toBe(0); + expect(player1.getStatStage(Stat.ATK)).toBe(-2); + expect(player2.getStatStage(Stat.ATK)).toBe(-2); + }); + + it("Player side + single battle Intimidate + Tickle - opponent loses stats", async () => { + game.override.ability(Abilities.MIRROR_ARMOR); + game.override.enemyAbility(Abilities.INTIMIDATE); + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + const userPokemon = game.scene.getPlayerPokemon()!; + + // Enemy has intimidate and uses tickle, enemy receives -2 atk and -1 defense + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.TICKLE, BattlerIndex.PLAYER); + await game.toNextTurn(); + + expect(enemyPokemon.getStatStage(Stat.DEF)).toBe(-1); + expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-2); + expect(userPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(userPokemon.getStatStage(Stat.DEF)).toBe(0); + }); + + it("Player side + double battle Intimidate + Tickle - opponents each lose -3 atk, -1 def", async () => { + game.override.battleType("double"); + game.override.ability(Abilities.MIRROR_ARMOR); + game.override.enemyAbility(Abilities.INTIMIDATE); + await game.classicMode.startBattle([ Species.BULBASAUR, Species.CHARMANDER ]); + + const [ enemy1, enemy2 ] = game.scene.getEnemyField(); + const [ player1, player2 ] = game.scene.getPlayerField(); + + game.move.select(Moves.SPLASH); + game.move.select(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.TICKLE, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.TICKLE, BattlerIndex.PLAYER_2); + await game.toNextTurn(); + + expect(player1.getStatStage(Stat.ATK)).toBe(0); + expect(player1.getStatStage(Stat.DEF)).toBe(0); + expect(player2.getStatStage(Stat.ATK)).toBe(0); + expect(player2.getStatStage(Stat.DEF)).toBe(0); + expect(enemy1.getStatStage(Stat.ATK)).toBe(-3); + expect(enemy1.getStatStage(Stat.DEF)).toBe(-1); + expect(enemy2.getStatStage(Stat.ATK)).toBe(-3); + expect(enemy2.getStatStage(Stat.DEF)).toBe(-1); + + }); + + it("Enemy side + single battle Intimidate + Tickle - player loses stats", async () => { + game.override.enemyAbility(Abilities.MIRROR_ARMOR); + game.override.ability(Abilities.INTIMIDATE); + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + const userPokemon = game.scene.getPlayerPokemon()!; + + // Enemy has intimidate and uses tickle, enemy receives -2 atk and -1 defense + game.move.select(Moves.TICKLE); + await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER); + await game.toNextTurn(); + + expect(userPokemon.getStatStage(Stat.DEF)).toBe(-1); + expect(userPokemon.getStatStage(Stat.ATK)).toBe(-2); + expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(enemyPokemon.getStatStage(Stat.DEF)).toBe(0); + }); + + it("Player side + single battle Intimidate + oppoenent has white smoke - no one loses stats", async () => { + game.override.enemyAbility(Abilities.WHITE_SMOKE); + game.override.ability(Abilities.MIRROR_ARMOR); + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + const userPokemon = game.scene.getPlayerPokemon()!; + + // Enemy has intimidate and uses tickle, enemy has white smoke, no one loses stats + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.TICKLE, BattlerIndex.PLAYER); + await game.toNextTurn(); + + expect(enemyPokemon.getStatStage(Stat.DEF)).toBe(0); + expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(userPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(userPokemon.getStatStage(Stat.DEF)).toBe(0); + }); + + it("Enemy side + single battle Intimidate + player has white smoke - no one loses stats", async () => { + game.override.ability(Abilities.WHITE_SMOKE); + game.override.enemyAbility(Abilities.MIRROR_ARMOR); + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + const userPokemon = game.scene.getPlayerPokemon()!; + + // Enemy has intimidate and uses tickle, enemy has white smoke, no one loses stats + game.move.select(Moves.TICKLE); + await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER); + await game.toNextTurn(); + + expect(enemyPokemon.getStatStage(Stat.DEF)).toBe(0); + expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(userPokemon.getStatStage(Stat.ATK)).toBe(0); + expect(userPokemon.getStatStage(Stat.DEF)).toBe(0); + }); + + it("Player side + single battle + opponent uses octolock - does not interact with mirror armor, player loses stats", async () => { + game.override.ability(Abilities.MIRROR_ARMOR); + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + const userPokemon = game.scene.getPlayerPokemon()!; + + // Enemy uses octolock, player loses stats at end of turn + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.OCTOLOCK, BattlerIndex.PLAYER); + await game.toNextTurn(); + + expect(enemyPokemon.getStatStage(Stat.DEF)).toBe(0); + expect(enemyPokemon.getStatStage(Stat.SPDEF)).toBe(0); + expect(userPokemon.getStatStage(Stat.DEF)).toBe(-1); + expect(userPokemon.getStatStage(Stat.SPDEF)).toBe(-1); + }); + + it("Enemy side + single battle + player uses octolock - does not interact with mirror armor, opponent loses stats", async () => { + game.override.enemyAbility(Abilities.MIRROR_ARMOR); + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + const userPokemon = game.scene.getPlayerPokemon()!; + + // Player uses octolock, enemy loses stats at end of turn + game.move.select(Moves.OCTOLOCK); + await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER); + await game.toNextTurn(); + + expect(userPokemon.getStatStage(Stat.DEF)).toBe(0); + expect(userPokemon.getStatStage(Stat.SPDEF)).toBe(0); + expect(enemyPokemon.getStatStage(Stat.DEF)).toBe(-1); + expect(enemyPokemon.getStatStage(Stat.SPDEF)).toBe(-1); + }); + + it("Both sides have mirror armor - does not loop, player loses attack", async () => { + game.override.enemyAbility(Abilities.MIRROR_ARMOR); + game.override.ability(Abilities.MIRROR_ARMOR); + game.override.ability(Abilities.INTIMIDATE); + await game.classicMode.startBattle([ Species.BULBASAUR ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + const userPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER); + await game.toNextTurn(); + + expect(userPokemon.getStatStage(Stat.ATK)).toBe(-1); + expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(0); + }); + + it("Single battle + sticky web applied player side - player switches out and enemy should lose -1 speed", async () => { + game.override.ability(Abilities.MIRROR_ARMOR); + await game.classicMode.startBattle([ Species.BULBASAUR, Species.CHARMANDER, Species.SQUIRTLE ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + const userPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.STICKY_WEB, BattlerIndex.PLAYER); + await game.toNextTurn(); + + game.doSwitchPokemon(1); + await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER); + await game.toNextTurn(); + + expect(userPokemon.getStatStage(Stat.SPD)).toBe(0); + expect(enemyPokemon.getStatStage(Stat.SPD)).toBe(-1); + }); + + it("Double battle + sticky web applied player side - player switches out and enemy 1 should lose -1 speed", async () => { + game.override.battleType("double"); + game.override.ability(Abilities.MIRROR_ARMOR); + await game.classicMode.startBattle([ Species.BULBASAUR, Species.CHARMANDER, Species.SQUIRTLE ]); + + const [ enemy1, enemy2 ] = game.scene.getEnemyField(); + const [ player1, player2 ] = game.scene.getPlayerField(); + + game.move.select(Moves.SPLASH); + game.move.select(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.STICKY_WEB, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.toNextTurn(); + + game.doSwitchPokemon(2); + game.move.select(Moves.SPLASH, 1); + await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER); + await game.forceEnemyMove(Moves.SPLASH, BattlerIndex.PLAYER_2); + await game.toNextTurn(); + + expect(enemy1.getStatStage(Stat.SPD)).toBe(-1); + expect(enemy2.getStatStage(Stat.SPD)).toBe(0); + expect(player1.getStatStage(Stat.SPD)).toBe(0); + expect(player2.getStatStage(Stat.SPD)).toBe(0); + }); +}); From 366c88517c36ae46fd00f540f3ae7f7b860e6234 Mon Sep 17 00:00:00 2001 From: AJ Fontaine <36677462+Fontbane@users.noreply.github.com> Date: Sun, 9 Feb 2025 03:41:59 -0500 Subject: [PATCH 2/3] [UI/UX] Show IVs on stats page of summary (#5172) * Show IVs on stats screen * Clearer text and gold perfect IVs --- src/ui/summary-ui-handler.ts | 50 +++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/src/ui/summary-ui-handler.ts b/src/ui/summary-ui-handler.ts index 3305b3f7aa2..cf5d40bc006 100644 --- a/src/ui/summary-ui-handler.ts +++ b/src/ui/summary-ui-handler.ts @@ -96,6 +96,9 @@ export default class SummaryUiHandler extends UiHandler { private friendshipText: Phaser.GameObjects.Text; private friendshipIcon: Phaser.GameObjects.Sprite; private friendshipOverlay: Phaser.GameObjects.Sprite; + private permStatsContainer: Phaser.GameObjects.Container; + private ivContainer: Phaser.GameObjects.Container; + private statsContainer: Phaser.GameObjects.Container; private descriptionScrollTween: Phaser.Tweens.Tween | null; private moveCursorBlinkTimer: Phaser.Time.TimerEvent | null; @@ -534,6 +537,10 @@ export default class SummaryUiHandler extends UiHandler { this.passiveContainer.nameText?.setVisible(!this.passiveContainer.descriptionText?.visible); this.passiveContainer.descriptionText?.setVisible(!this.passiveContainer.descriptionText.visible); this.passiveContainer.labelImage.setVisible(!this.passiveContainer.labelImage.visible); + } else if (this.cursor === Page.STATS) { + //Show IVs + this.permStatsContainer.setVisible(!this.permStatsContainer.visible); + this.ivContainer.setVisible(!this.ivContainer.visible); } } else if (button === Button.CANCEL) { if (this.summaryUiMode === SummaryUiMode.LEARN_MOVE) { @@ -877,8 +884,13 @@ export default class SummaryUiHandler extends UiHandler { profileContainer.add(memoText); break; case Page.STATS: - const statsContainer = globalScene.add.container(0, -pageBg.height); - pageContainer.add(statsContainer); + this.statsContainer = globalScene.add.container(0, -pageBg.height); + pageContainer.add(this.statsContainer); + this.permStatsContainer = globalScene.add.container(27, 56); + this.statsContainer.add(this.permStatsContainer); + this.ivContainer = globalScene.add.container(27, 56); + this.statsContainer.add(this.ivContainer); + this.statsContainer.setVisible(true); PERMANENT_STATS.forEach((stat, s) => { const statName = i18next.t(getStatKey(stat)); @@ -887,18 +899,27 @@ export default class SummaryUiHandler extends UiHandler { const natureStatMultiplier = getNatureStatMultiplier(this.pokemon?.getNature()!, s); // TODO: is this bang correct? - const statLabel = addTextObject(27 + 115 * colIndex + (colIndex === 1 ? 5 : 0), 56 + 16 * rowIndex, statName, natureStatMultiplier === 1 ? TextStyle.SUMMARY : natureStatMultiplier > 1 ? TextStyle.SUMMARY_PINK : TextStyle.SUMMARY_BLUE); + const statLabel = addTextObject(115 * colIndex + (colIndex === 1 ? 5 : 0), 16 * rowIndex, statName, natureStatMultiplier === 1 ? TextStyle.SUMMARY : natureStatMultiplier > 1 ? TextStyle.SUMMARY_PINK : TextStyle.SUMMARY_BLUE); + const ivLabel = addTextObject(115 * colIndex + (colIndex === 1 ? 5 : 0), 16 * rowIndex, statName, this.pokemon?.ivs[stat] === 31 ? TextStyle.SUMMARY_GOLD : TextStyle.SUMMARY); + statLabel.setOrigin(0.5, 0); - statsContainer.add(statLabel); + ivLabel.setOrigin(0.5, 0); + this.permStatsContainer.add(statLabel); + this.ivContainer.add(ivLabel); const statValueText = stat !== Stat.HP ? Utils.formatStat(this.pokemon?.getStat(stat)!) // TODO: is this bang correct? : `${Utils.formatStat(this.pokemon?.hp!, true)}/${Utils.formatStat(this.pokemon?.getMaxHp()!, true)}`; // TODO: are those bangs correct? + const ivText = `${this.pokemon?.ivs[stat]}/31`; - const statValue = addTextObject(120 + 88 * colIndex, 56 + 16 * rowIndex, statValueText, TextStyle.WINDOW_ALT); + const statValue = addTextObject(93 + 88 * colIndex, 16 * rowIndex, statValueText, TextStyle.WINDOW_ALT); statValue.setOrigin(1, 0); - statsContainer.add(statValue); + this.permStatsContainer.add(statValue); + const ivValue = addTextObject(93 + 88 * colIndex, 16 * rowIndex, ivText, TextStyle.WINDOW_ALT); + ivValue.setOrigin(1, 0); + this.ivContainer.add(ivValue); }); + this.ivContainer.setVisible(false); const itemModifiers = (globalScene.findModifiers(m => m instanceof PokemonHeldItemModifier && m.pokemonId === this.pokemon?.id, this.playerParty) as PokemonHeldItemModifier[]) @@ -908,7 +929,7 @@ export default class SummaryUiHandler extends UiHandler { const icon = item.getIcon(true); icon.setPosition((i % 17) * 12 + 3, 14 * Math.floor(i / 17) + 15); - statsContainer.add(icon); + this.statsContainer.add(icon); icon.setInteractive(new Phaser.Geom.Rectangle(0, 0, 32, 32), Phaser.Geom.Rectangle.Contains); icon.on("pointerover", () => globalScene.ui.showTooltip(item.type.name, item.type.getDescription(), true)); @@ -924,26 +945,26 @@ export default class SummaryUiHandler extends UiHandler { const expLabel = addTextObject(6, 112, i18next.t("pokemonSummary:expPoints"), TextStyle.SUMMARY); expLabel.setOrigin(0, 0); - statsContainer.add(expLabel); + this.statsContainer.add(expLabel); const nextLvExpLabel = addTextObject(6, 128, i18next.t("pokemonSummary:nextLv"), TextStyle.SUMMARY); nextLvExpLabel.setOrigin(0, 0); - statsContainer.add(nextLvExpLabel); + this.statsContainer.add(nextLvExpLabel); const expText = addTextObject(208, 112, pkmExp.toString(), TextStyle.WINDOW_ALT); expText.setOrigin(1, 0); - statsContainer.add(expText); + this.statsContainer.add(expText); const nextLvExp = pkmLvl < globalScene.getMaxExpLevel() ? getLevelTotalExp(pkmLvl + 1, pkmSpeciesGrowthRate) - pkmExp : 0; const nextLvExpText = addTextObject(208, 128, nextLvExp.toString(), TextStyle.WINDOW_ALT); nextLvExpText.setOrigin(1, 0); - statsContainer.add(nextLvExpText); + this.statsContainer.add(nextLvExpText); const expOverlay = globalScene.add.image(140, 145, "summary_stats_overlay_exp"); expOverlay.setOrigin(0, 0); - statsContainer.add(expOverlay); + this.statsContainer.add(expOverlay); const expMaskRect = globalScene.make.graphics({}); expMaskRect.setScale(6); @@ -954,6 +975,11 @@ export default class SummaryUiHandler extends UiHandler { const expMask = expMaskRect.createGeometryMask(); expOverlay.setMask(expMask); + this.abilityPrompt = globalScene.add.image(0, 0, !globalScene.inputController?.gamepadSupport ? "summary_profile_prompt_z" : "summary_profile_prompt_a"); + this.abilityPrompt.setPosition(8, 47); + this.abilityPrompt.setVisible(true); + this.abilityPrompt.setOrigin(0, 0); + this.statsContainer.add(this.abilityPrompt); break; case Page.MOVES: this.movesContainer = globalScene.add.container(5, -pageBg.height + 26); From 612e6a25c124876e1029c6e4160cbbc6866067ce Mon Sep 17 00:00:00 2001 From: damocleas Date: Sun, 9 Feb 2025 04:13:06 -0500 Subject: [PATCH 3/3] Revert "[UI/UX] Show IVs on stats page of summary (#5172)" (#5284) This reverts commit 366c88517c36ae46fd00f540f3ae7f7b860e6234. --- src/ui/summary-ui-handler.ts | 50 +++++++++--------------------------- 1 file changed, 12 insertions(+), 38 deletions(-) diff --git a/src/ui/summary-ui-handler.ts b/src/ui/summary-ui-handler.ts index cf5d40bc006..3305b3f7aa2 100644 --- a/src/ui/summary-ui-handler.ts +++ b/src/ui/summary-ui-handler.ts @@ -96,9 +96,6 @@ export default class SummaryUiHandler extends UiHandler { private friendshipText: Phaser.GameObjects.Text; private friendshipIcon: Phaser.GameObjects.Sprite; private friendshipOverlay: Phaser.GameObjects.Sprite; - private permStatsContainer: Phaser.GameObjects.Container; - private ivContainer: Phaser.GameObjects.Container; - private statsContainer: Phaser.GameObjects.Container; private descriptionScrollTween: Phaser.Tweens.Tween | null; private moveCursorBlinkTimer: Phaser.Time.TimerEvent | null; @@ -537,10 +534,6 @@ export default class SummaryUiHandler extends UiHandler { this.passiveContainer.nameText?.setVisible(!this.passiveContainer.descriptionText?.visible); this.passiveContainer.descriptionText?.setVisible(!this.passiveContainer.descriptionText.visible); this.passiveContainer.labelImage.setVisible(!this.passiveContainer.labelImage.visible); - } else if (this.cursor === Page.STATS) { - //Show IVs - this.permStatsContainer.setVisible(!this.permStatsContainer.visible); - this.ivContainer.setVisible(!this.ivContainer.visible); } } else if (button === Button.CANCEL) { if (this.summaryUiMode === SummaryUiMode.LEARN_MOVE) { @@ -884,13 +877,8 @@ export default class SummaryUiHandler extends UiHandler { profileContainer.add(memoText); break; case Page.STATS: - this.statsContainer = globalScene.add.container(0, -pageBg.height); - pageContainer.add(this.statsContainer); - this.permStatsContainer = globalScene.add.container(27, 56); - this.statsContainer.add(this.permStatsContainer); - this.ivContainer = globalScene.add.container(27, 56); - this.statsContainer.add(this.ivContainer); - this.statsContainer.setVisible(true); + const statsContainer = globalScene.add.container(0, -pageBg.height); + pageContainer.add(statsContainer); PERMANENT_STATS.forEach((stat, s) => { const statName = i18next.t(getStatKey(stat)); @@ -899,27 +887,18 @@ export default class SummaryUiHandler extends UiHandler { const natureStatMultiplier = getNatureStatMultiplier(this.pokemon?.getNature()!, s); // TODO: is this bang correct? - const statLabel = addTextObject(115 * colIndex + (colIndex === 1 ? 5 : 0), 16 * rowIndex, statName, natureStatMultiplier === 1 ? TextStyle.SUMMARY : natureStatMultiplier > 1 ? TextStyle.SUMMARY_PINK : TextStyle.SUMMARY_BLUE); - const ivLabel = addTextObject(115 * colIndex + (colIndex === 1 ? 5 : 0), 16 * rowIndex, statName, this.pokemon?.ivs[stat] === 31 ? TextStyle.SUMMARY_GOLD : TextStyle.SUMMARY); - + const statLabel = addTextObject(27 + 115 * colIndex + (colIndex === 1 ? 5 : 0), 56 + 16 * rowIndex, statName, natureStatMultiplier === 1 ? TextStyle.SUMMARY : natureStatMultiplier > 1 ? TextStyle.SUMMARY_PINK : TextStyle.SUMMARY_BLUE); statLabel.setOrigin(0.5, 0); - ivLabel.setOrigin(0.5, 0); - this.permStatsContainer.add(statLabel); - this.ivContainer.add(ivLabel); + statsContainer.add(statLabel); const statValueText = stat !== Stat.HP ? Utils.formatStat(this.pokemon?.getStat(stat)!) // TODO: is this bang correct? : `${Utils.formatStat(this.pokemon?.hp!, true)}/${Utils.formatStat(this.pokemon?.getMaxHp()!, true)}`; // TODO: are those bangs correct? - const ivText = `${this.pokemon?.ivs[stat]}/31`; - const statValue = addTextObject(93 + 88 * colIndex, 16 * rowIndex, statValueText, TextStyle.WINDOW_ALT); + const statValue = addTextObject(120 + 88 * colIndex, 56 + 16 * rowIndex, statValueText, TextStyle.WINDOW_ALT); statValue.setOrigin(1, 0); - this.permStatsContainer.add(statValue); - const ivValue = addTextObject(93 + 88 * colIndex, 16 * rowIndex, ivText, TextStyle.WINDOW_ALT); - ivValue.setOrigin(1, 0); - this.ivContainer.add(ivValue); + statsContainer.add(statValue); }); - this.ivContainer.setVisible(false); const itemModifiers = (globalScene.findModifiers(m => m instanceof PokemonHeldItemModifier && m.pokemonId === this.pokemon?.id, this.playerParty) as PokemonHeldItemModifier[]) @@ -929,7 +908,7 @@ export default class SummaryUiHandler extends UiHandler { const icon = item.getIcon(true); icon.setPosition((i % 17) * 12 + 3, 14 * Math.floor(i / 17) + 15); - this.statsContainer.add(icon); + statsContainer.add(icon); icon.setInteractive(new Phaser.Geom.Rectangle(0, 0, 32, 32), Phaser.Geom.Rectangle.Contains); icon.on("pointerover", () => globalScene.ui.showTooltip(item.type.name, item.type.getDescription(), true)); @@ -945,26 +924,26 @@ export default class SummaryUiHandler extends UiHandler { const expLabel = addTextObject(6, 112, i18next.t("pokemonSummary:expPoints"), TextStyle.SUMMARY); expLabel.setOrigin(0, 0); - this.statsContainer.add(expLabel); + statsContainer.add(expLabel); const nextLvExpLabel = addTextObject(6, 128, i18next.t("pokemonSummary:nextLv"), TextStyle.SUMMARY); nextLvExpLabel.setOrigin(0, 0); - this.statsContainer.add(nextLvExpLabel); + statsContainer.add(nextLvExpLabel); const expText = addTextObject(208, 112, pkmExp.toString(), TextStyle.WINDOW_ALT); expText.setOrigin(1, 0); - this.statsContainer.add(expText); + statsContainer.add(expText); const nextLvExp = pkmLvl < globalScene.getMaxExpLevel() ? getLevelTotalExp(pkmLvl + 1, pkmSpeciesGrowthRate) - pkmExp : 0; const nextLvExpText = addTextObject(208, 128, nextLvExp.toString(), TextStyle.WINDOW_ALT); nextLvExpText.setOrigin(1, 0); - this.statsContainer.add(nextLvExpText); + statsContainer.add(nextLvExpText); const expOverlay = globalScene.add.image(140, 145, "summary_stats_overlay_exp"); expOverlay.setOrigin(0, 0); - this.statsContainer.add(expOverlay); + statsContainer.add(expOverlay); const expMaskRect = globalScene.make.graphics({}); expMaskRect.setScale(6); @@ -975,11 +954,6 @@ export default class SummaryUiHandler extends UiHandler { const expMask = expMaskRect.createGeometryMask(); expOverlay.setMask(expMask); - this.abilityPrompt = globalScene.add.image(0, 0, !globalScene.inputController?.gamepadSupport ? "summary_profile_prompt_z" : "summary_profile_prompt_a"); - this.abilityPrompt.setPosition(8, 47); - this.abilityPrompt.setVisible(true); - this.abilityPrompt.setOrigin(0, 0); - this.statsContainer.add(this.abilityPrompt); break; case Page.MOVES: this.movesContainer = globalScene.add.container(5, -pageBg.height + 26);