From d9d119aadaf37ad8731aee65ea38e2f7d4363c85 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sat, 21 Jun 2025 13:08:20 -0400 Subject: [PATCH 1/3] Added move condition to Transform + fixed up imposter target selection --- src/data/abilities/ability.ts | 72 +++++++++-------------- src/data/moves/move.ts | 22 ++++--- src/field/pokemon.ts | 82 ++++++++++++++++----------- src/phases/pokemon-transform-phase.ts | 5 +- 4 files changed, 93 insertions(+), 88 deletions(-) diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 0846d5602cc..8d13e4a0de3 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -75,7 +75,7 @@ import type { AbAttrString, AbAttrMap, } from "#app/@types/ability-types"; -import type { BattlerIndex } from "#enums/battler-index"; +import { BattlerIndex } from "#enums/battler-index"; import type Move from "#app/data/moves/move"; import type { ArenaTrapTag, SuppressAbilitiesTag } from "#app/data/arena-tag"; import type { Constructor } from "#app/utils/common"; @@ -3870,60 +3870,39 @@ export class PostSummonCopyAllyStatsAbAttr extends PostSummonAbAttr { * Attribute used by {@linkcode AbilityId.IMPOSTER} to transform into a random opposing pokemon on entry. */ export class PostSummonTransformAbAttr extends PostSummonAbAttr { + private targetIndex: BattlerIndex = BattlerIndex.ATTACKER; constructor() { super(true, false); } - private getTarget(targets: Pokemon[]): Pokemon { - let target: Pokemon = targets[0]; - if (targets.length > 1) { - globalScene.executeWithSeedOffset(() => { - // in a double battle, if one of the opposing pokemon is fused the other one will be chosen - // if both are fused, then Imposter will fail below - if (targets[0].fusionSpecies) { - target = targets[1]; - return; - } - if (targets[1].fusionSpecies) { - target = targets[0]; - return; - } - target = randSeedItem(targets); - }, globalScene.currentBattle.waveIndex); - } else { - target = targets[0]; + /** + * Return the correct opponent for Imposter to copy, barring enemies with fusions, substitutes and illusions. + * @param user - The {@linkcode Pokemon} with this ability. + * @returns The {@linkcode Pokemon} to transform into, or `undefined` if none are eligible. + * @remarks + * This sets the private `targetIndex` field to the target's {@linkcode BattlerIndex} on success. + */ + private getTarget(user: Pokemon): Pokemon | undefined { + // As opposed to the mainline behavior of "always copy the opposite slot", + // PKR Imposter instead attempts to copy a random eligible opposing Pokemon meeting Transform's criteria. + // If none are eligible to copy, it will not activate. + const targets = user.getOpponents().filter(opp => user.canTransformInto(opp)); + if (targets.length === 0) { + return undefined; } - target = target!; - - return target; + const mon = targets[user.randBattleSeedInt(targets.length)]; + this.targetIndex = mon.getBattlerIndex(); + return mon; } - override canApplyPostSummon(pokemon: Pokemon, _passive: boolean, simulated: boolean, _args: any[]): boolean { - const targets = pokemon.getOpponents(); - const target = this.getTarget(targets); - - if (target?.summonData?.illusion || pokemon?.isTransformed() || target?.isTransformed()) { - return false; - } - - if (simulated || !targets.length) { - return simulated; - } - - // transforming from or into fusion pokemon causes various problems (including crashes and save corruption) - return !(this.getTarget(targets).fusionSpecies || pokemon.fusionSpecies); + override canApplyPostSummon(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _args: any[]): boolean { + const target = this.getTarget(pokemon); + return !!target; } override applyPostSummon(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _args: any[]): void { - const target = this.getTarget(pokemon.getOpponents()); - - globalScene.phaseManager.unshiftNew( - "PokemonTransformPhase", - pokemon.getBattlerIndex(), - target.getBattlerIndex(), - true, - ); + globalScene.phaseManager.unshiftNew("PokemonTransformPhase", pokemon.getBattlerIndex(), this.targetIndex, true); } } @@ -3997,7 +3976,7 @@ export class PostSummonFormChangeByWeatherAbAttr extends PostSummonAbAttr { /** * Attribute implementing the effects of {@link https://bulbapedia.bulbagarden.net/wiki/Commander_(Ability) | Commander}. * When the source of an ability with this attribute detects a Dondozo as their active ally, the source "jumps - * into the Dondozo's mouth," sharply boosting the Dondozo's stats, cancelling the source's moves, and + * into the Dondozo's mouth"m sharply boosting the Dondozo's stats, cancelling the source's moves, and * causing attacks that target the source to always miss. */ export class CommanderAbAttr extends AbAttr { @@ -8402,7 +8381,8 @@ export function initAbilities() { .bypassFaint(), new Ability(AbilityId.IMPOSTER, 5) .attr(PostSummonTransformAbAttr) - .uncopiable(), + .uncopiable() + .edgeCase(), // Should copy rage fist hit count new Ability(AbilityId.INFILTRATOR, 5) .attr(InfiltratorAbAttr) .partial(), // does not bypass Mist diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 066114e6aca..529d2ba881e 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -7606,19 +7606,23 @@ export class SuppressAbilitiesIfActedAttr extends MoveEffectAttr { } /** - * Used by Transform + * Attribute used to transform into the target on move use. + * + * Used for {@linkcode MoveId.TRANSFORM}. */ export class TransformAttr extends MoveEffectAttr { override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (!super.apply(user, target, move, args) || (target.isTransformed() || user.isTransformed())) { - globalScene.phaseManager.queueMessage(i18next.t("battle:attackFailed")); + if (!super.apply(user, target, move, args)) { return false; } globalScene.phaseManager.unshiftNew("PokemonTransformPhase", user.getBattlerIndex(), target.getBattlerIndex()); - return true; } + + getCondition(): MoveConditionFunc { + return (user, target) => user.canTransformInto(target) + } } /** @@ -8840,12 +8844,12 @@ export function initMoves() { .makesContact(false), new StatusMove(MoveId.TRANSFORM, PokemonType.NORMAL, -1, 10, -1, 0, 1) .attr(TransformAttr) - .condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE)) - .condition((user, target, move) => !target.summonData.illusion && !user.summonData.illusion) - // transforming from or into fusion pokemon causes various problems (such as crashes) - .condition((user, target, move) => !target.getTag(BattlerTagType.SUBSTITUTE) && !user.fusionSpecies && !target.fusionSpecies) .ignoresProtect() - // Transforming should copy the target's rage fist hit count + /* Transform: + * Does not copy the target's rage fist hit count + * Does not copy the target's volatile status conditions (ie BattlerTags) + * Renders user typeless when copying typeless opponent (should revert to original typing) + */ .edgeCase(), new AttackMove(MoveId.BUBBLE, PokemonType.WATER, MoveCategory.SPECIAL, 40, 100, 30, 10, 0, 1) .attr(StatStageChangeAttr, [ Stat.SPD ], -1) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index deeffd736e8..85fc562e185 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1066,6 +1066,30 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return this.summonData.speciesForm !== null; } + /** + * Return whether this Pokemon can transform into an opposing Pokemon. + * @param target - The {@linkcode Pokemon} being transformed into. + * @returns Whether this Pokemon can transform into `target`. + */ + canTransformInto(target: Pokemon): boolean { + return !( + // Neither pokemon can be already transformed + ( + this.isTransformed() || + target.isTransformed() || + // Neither pokemon can be behind an illusion + target.summonData.illusion || + this.summonData.illusion || + // The target cannot be behind a substitute + target.getTag(BattlerTagType.SUBSTITUTE) || + // Transforming to/from fusion pokemon causes various problems (crashes, etc.) + // TODO: Consider lifting restriction once bug is fixed + this.isFusion() || + target.isFusion() + ) + ); + } + /** * @param {boolean} useIllusion - Whether we want the fusionSpeciesForm of the illusion or not. */ @@ -1280,39 +1304,39 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { /** * Retrieves the entire set of stats of this {@linkcode Pokemon}. - * @param bypassSummonData - whether to use actual stats or in-battle overriden stats from Transform; default `true` - * @returns the numeric values of this {@linkcode Pokemon}'s stats + * @param bypassSummonData - Whether to prefer actual stats (`true`) or in-battle overridden stats (`false`); default `true` + * @returns The numeric values of this {@linkcode Pokemon}'s stats as an array. */ getStats(bypassSummonData = true): number[] { - if (!bypassSummonData && this.summonData.stats) { - return this.summonData.stats; + if (!bypassSummonData) { + // Only grab summon data stats if nonzero + return this.summonData.stats.map((s, i) => s || this.stats[i]); } return this.stats; } /** * Retrieves the corresponding {@linkcode PermanentStat} of the {@linkcode Pokemon}. - * @param stat the desired {@linkcode PermanentStat} - * @param bypassSummonData prefer actual stats (`true` by default) or in-battle overridden stats (`false`) - * @returns the numeric value of the desired {@linkcode Stat} + * @param stat - The desired {@linkcode PermanentStat}. + * @param bypassSummonData - Whether to prefer actual stats (`true`) or in-battle overridden stats (`false`); default `true` + * @returns The numeric value of the desired {@linkcode Stat}. */ getStat(stat: PermanentStat, bypassSummonData = true): number { - if (!bypassSummonData && this.summonData.stats[stat] !== 0) { - return this.summonData.stats[stat]; + if (!bypassSummonData) { + // 0 = no override + return this.summonData.stats[stat] || this.stats[stat]; } return this.stats[stat]; } /** - * Writes the value to the corrseponding {@linkcode PermanentStat} of the {@linkcode Pokemon}. - * - * Note that this does nothing if {@linkcode value} is less than 0. - * @param stat the desired {@linkcode PermanentStat} to be overwritten - * @param value the desired numeric value - * @param bypassSummonData write to actual stats (`true` by default) or in-battle overridden stats (`false`) + * Change one of this {@linkcode Pokemon}'s {@linkcode PermanentStat}s to the specified value. + * @param stat - The {@linkcode PermanentStat} to be overwritten. + * @param value - The stat value to set. Ignored if `<=0` + * @param bypassSummonData - Whether to write to actual stats (`true`) or in-battle overridden stats (`false`); default `true` */ setStat(stat: PermanentStat, value: number, bypassSummonData = true): void { - if (value < 0) { + if (value <= 0) { return; } @@ -1328,31 +1352,25 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * @returns the numeric values of the {@linkcode Pokemon}'s in-battle stat stages if available, a fresh stat stage array otherwise */ getStatStages(): number[] { - return this.summonData ? this.summonData.statStages : [0, 0, 0, 0, 0, 0, 0]; + return this.summonData.statStages; } /** - * Retrieves the in-battle stage of the specified {@linkcode BattleStat}. - * @param stat the {@linkcode BattleStat} whose stage is desired - * @returns the stage of the desired {@linkcode BattleStat} if available, 0 otherwise + * Retrieve the value of the given stat stage for this {@linkcode Pokemon}. + * @param stat - The {@linkcode BattleStat} to retrieve the stat stage for. + * @returns The value of the desired stat stage as a number within the range `[-6, +6]`. */ getStatStage(stat: BattleStat): number { - return this.summonData ? this.summonData.statStages[stat - 1] : 0; + return this.summonData.statStages[stat - 1]; } /** - * Writes the value to the in-battle stage of the corresponding {@linkcode BattleStat} of the {@linkcode Pokemon}. - * - * Note that, if the value is not within a range of [-6, 6], it will be forced to the closest range bound. - * @param stat the {@linkcode BattleStat} whose stage is to be overwritten - * @param value the desired numeric value + * Sets this {@linkcode Pokemon}'s in-battle stat stage to the corresponding value. + * @param stat - The {@linkcode BattleStat} whose stage is to be overwritten. + * @param value - The value of the stat stage to set, forcibly clamped within the range `[-6, +6]`. */ setStatStage(stat: BattleStat, value: number): void { - if (value >= -6) { - this.summonData.statStages[stat - 1] = Math.min(value, 6); - } else { - this.summonData.statStages[stat - 1] = Math.max(value, -6); - } + this.summonData.statStages[stat - 1] = Phaser.Math.Clamp(value, -6, 6); } /** @@ -3296,7 +3314,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * @param onField - whether to also check if the pokemon is currently on the field (defaults to true) */ getOpponents(onField = true): Pokemon[] { - return ((this.isPlayer() ? globalScene.getEnemyField() : globalScene.getPlayerField()) as Pokemon[]).filter(p => + return (this.isPlayer() ? globalScene.getEnemyField() : globalScene.getPlayerField()).filter(p => p.isActive(onField), ); } diff --git a/src/phases/pokemon-transform-phase.ts b/src/phases/pokemon-transform-phase.ts index 938915309d9..2e748b6758b 100644 --- a/src/phases/pokemon-transform-phase.ts +++ b/src/phases/pokemon-transform-phase.ts @@ -21,12 +21,13 @@ export class PokemonTransformPhase extends PokemonPhase { super(userIndex); this.targetIndex = targetIndex; + this.playSound = playSound; } public override start(): void { const user = this.getPokemon(); - const target = globalScene.getField(true).find(p => p.getBattlerIndex() === this.targetIndex); + const target = globalScene.getField()[this.targetIndex]; if (!target) { this.end(); @@ -58,6 +59,8 @@ export class PokemonTransformPhase extends PokemonPhase { console.warn(`Transform: somehow iterating over a ${m} value when copying moveset!`); return new PokemonMove(MoveId.NONE); }); + + // TODO: This should fallback to the target's original typing if none are left (from Burn Up, etc.) user.summonData.types = target.getTypes(); const promises = [user.updateInfo()]; From 4b8026502dd4fd5e5aea54b2c96e99d682d7df55 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sat, 21 Jun 2025 13:48:50 -0400 Subject: [PATCH 2/3] Condensed tests into 1 file + added more automated tests This includes a couple regression tests for the fusion shenanigans --- src/data/abilities/ability.ts | 2 +- test/abilities/imposter.test.ts | 188 ------------- test/moves/transform-imposter.test.ts | 379 ++++++++++++++++++++++++++ test/moves/transform.test.ts | 185 ------------- test/testUtils/phaseInterceptor.ts | 2 + 5 files changed, 382 insertions(+), 374 deletions(-) delete mode 100644 test/abilities/imposter.test.ts create mode 100644 test/moves/transform-imposter.test.ts delete mode 100644 test/moves/transform.test.ts diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 8d13e4a0de3..6346a7c1d26 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -3976,7 +3976,7 @@ export class PostSummonFormChangeByWeatherAbAttr extends PostSummonAbAttr { /** * Attribute implementing the effects of {@link https://bulbapedia.bulbagarden.net/wiki/Commander_(Ability) | Commander}. * When the source of an ability with this attribute detects a Dondozo as their active ally, the source "jumps - * into the Dondozo's mouth"m sharply boosting the Dondozo's stats, cancelling the source's moves, and + * into the Dondozo's mouth", sharply boosting the Dondozo's stats, cancelling the source's moves, and * causing attacks that target the source to always miss. */ export class CommanderAbAttr extends AbAttr { diff --git a/test/abilities/imposter.test.ts b/test/abilities/imposter.test.ts deleted file mode 100644 index 30491139877..00000000000 --- a/test/abilities/imposter.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import Phaser from "phaser"; -import GameManager from "#test/testUtils/gameManager"; -import { SpeciesId } from "#enums/species-id"; -import { TurnEndPhase } from "#app/phases/turn-end-phase"; -import { MoveId } from "#enums/move-id"; -import { Stat, BATTLE_STATS, EFFECTIVE_STATS } from "#enums/stat"; -import { AbilityId } from "#enums/ability-id"; - -// TODO: Add more tests once Imposter is fully implemented -describe("Abilities - Imposter", () => { - 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 - .battleStyle("single") - .enemySpecies(SpeciesId.MEW) - .enemyLevel(200) - .enemyAbility(AbilityId.BEAST_BOOST) - .enemyPassiveAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH) - .ability(AbilityId.IMPOSTER) - .moveset(MoveId.SPLASH); - }); - - it("should copy species, ability, gender, all stats except HP, all stat stages, moveset, and types of target", async () => { - await game.classicMode.startBattle([SpeciesId.DITTO]); - - game.move.select(MoveId.SPLASH); - await game.phaseInterceptor.to(TurnEndPhase); - - const player = game.scene.getPlayerPokemon()!; - const enemy = game.scene.getEnemyPokemon()!; - - expect(player.getSpeciesForm().speciesId).toBe(enemy.getSpeciesForm().speciesId); - expect(player.getAbility()).toBe(enemy.getAbility()); - expect(player.getGender()).toBe(enemy.getGender()); - - expect(player.getStat(Stat.HP, false)).not.toBe(enemy.getStat(Stat.HP)); - for (const s of EFFECTIVE_STATS) { - expect(player.getStat(s, false)).toBe(enemy.getStat(s, false)); - } - - for (const s of BATTLE_STATS) { - expect(player.getStatStage(s)).toBe(enemy.getStatStage(s)); - } - - const playerMoveset = player.getMoveset(); - const enemyMoveset = player.getMoveset(); - - expect(playerMoveset.length).toBe(enemyMoveset.length); - for (let i = 0; i < playerMoveset.length && i < enemyMoveset.length; i++) { - expect(playerMoveset[i]?.moveId).toBe(enemyMoveset[i]?.moveId); - } - - const playerTypes = player.getTypes(); - const enemyTypes = enemy.getTypes(); - - expect(playerTypes.length).toBe(enemyTypes.length); - for (let i = 0; i < playerTypes.length && i < enemyTypes.length; i++) { - expect(playerTypes[i]).toBe(enemyTypes[i]); - } - }); - - it("should copy in-battle overridden stats", async () => { - game.override.enemyMoveset([MoveId.POWER_SPLIT]); - - await game.classicMode.startBattle([SpeciesId.DITTO]); - - const player = game.scene.getPlayerPokemon()!; - const enemy = game.scene.getEnemyPokemon()!; - - const avgAtk = Math.floor((player.getStat(Stat.ATK, false) + enemy.getStat(Stat.ATK, false)) / 2); - const avgSpAtk = Math.floor((player.getStat(Stat.SPATK, false) + enemy.getStat(Stat.SPATK, false)) / 2); - - game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(player.getStat(Stat.ATK, false)).toBe(avgAtk); - expect(enemy.getStat(Stat.ATK, false)).toBe(avgAtk); - - expect(player.getStat(Stat.SPATK, false)).toBe(avgSpAtk); - expect(enemy.getStat(Stat.SPATK, false)).toBe(avgSpAtk); - }); - - it("should set each move's pp to a maximum of 5", async () => { - game.override.enemyMoveset([MoveId.SWORDS_DANCE, MoveId.GROWL, MoveId.SKETCH, MoveId.RECOVER]); - - await game.classicMode.startBattle([SpeciesId.DITTO]); - const player = game.scene.getPlayerPokemon()!; - - game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to(TurnEndPhase); - - player.getMoveset().forEach(move => { - // Should set correct maximum PP without touching `ppUp` - if (move) { - if (move.moveId === MoveId.SKETCH) { - expect(move.getMovePp()).toBe(1); - } else { - expect(move.getMovePp()).toBe(5); - } - expect(move.ppUp).toBe(0); - } - }); - }); - - it("should activate its ability if it copies one that activates on summon", async () => { - game.override.enemyAbility(AbilityId.INTIMIDATE); - - await game.classicMode.startBattle([SpeciesId.DITTO]); - - game.move.select(MoveId.TACKLE); - await game.phaseInterceptor.to("MoveEndPhase"); - - expect(game.scene.getEnemyPokemon()?.getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should persist transformed attributes across reloads", async () => { - game.override.moveset([MoveId.ABSORB]); - - await game.classicMode.startBattle([SpeciesId.DITTO]); - - const player = game.scene.getPlayerPokemon()!; - const enemy = game.scene.getEnemyPokemon()!; - - game.move.select(MoveId.SPLASH); - await game.doKillOpponents(); - await game.toNextWave(); - - expect(game.scene.phaseManager.getCurrentPhase()?.constructor.name).toBe("CommandPhase"); - expect(game.scene.currentBattle.waveIndex).toBe(2); - - await game.reload.reloadSession(); - - const playerReloaded = game.scene.getPlayerPokemon()!; - const playerMoveset = player.getMoveset(); - - expect(playerReloaded.getSpeciesForm().speciesId).toBe(enemy.getSpeciesForm().speciesId); - expect(playerReloaded.getAbility()).toBe(enemy.getAbility()); - expect(playerReloaded.getGender()).toBe(enemy.getGender()); - - expect(playerReloaded.getStat(Stat.HP, false)).not.toBe(enemy.getStat(Stat.HP)); - for (const s of EFFECTIVE_STATS) { - expect(playerReloaded.getStat(s, false)).toBe(enemy.getStat(s, false)); - } - - expect(playerMoveset.length).toEqual(1); - expect(playerMoveset[0]?.moveId).toEqual(MoveId.SPLASH); - }); - - it("should stay transformed with the correct form after reload", async () => { - game.override.moveset([MoveId.ABSORB]).enemySpecies(SpeciesId.UNOWN); - await game.classicMode.startBattle([SpeciesId.DITTO]); - - const enemy = game.scene.getEnemyPokemon()!; - - // change form - enemy.species.forms[5]; - enemy.species.formIndex = 5; - - game.move.select(MoveId.SPLASH); - await game.doKillOpponents(); - await game.toNextWave(); - - expect(game.scene.phaseManager.getCurrentPhase()?.constructor.name).toBe("CommandPhase"); - expect(game.scene.currentBattle.waveIndex).toBe(2); - - await game.reload.reloadSession(); - - const playerReloaded = game.scene.getPlayerPokemon()!; - - expect(playerReloaded.getSpeciesForm().speciesId).toBe(enemy.getSpeciesForm().speciesId); - expect(playerReloaded.getSpeciesForm().formIndex).toBe(enemy.getSpeciesForm().formIndex); - }); -}); diff --git a/test/moves/transform-imposter.test.ts b/test/moves/transform-imposter.test.ts new file mode 100644 index 00000000000..11fb92ec97c --- /dev/null +++ b/test/moves/transform-imposter.test.ts @@ -0,0 +1,379 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; +import Phaser from "phaser"; +import GameManager from "#test/testUtils/gameManager"; +import { SpeciesId } from "#enums/species-id"; +import { MoveId } from "#enums/move-id"; +import { Stat } from "#enums/stat"; +import { AbilityId } from "#enums/ability-id"; +import { BattlerIndex } from "#enums/battler-index"; +import { StatusEffect } from "#enums/status-effect"; +import { MoveResult } from "#enums/move-result"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { Status } from "#app/data/status-effect"; +import { PokemonType } from "#enums/pokemon-type"; +import { BerryType } from "#enums/berry-type"; +import type { EnemyPokemon } from "#app/field/pokemon"; +import Pokemon from "#app/field/pokemon"; +import { BattleType } from "#enums/battle-type"; + +// TODO: Add more tests once Transform/Imposter are fully implemented +describe("Transforming Effects", () => { + 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 + .battleStyle("single") + .enemySpecies(SpeciesId.MEW) + .enemyLevel(200) + .enemyAbility(AbilityId.BEAST_BOOST) + .enemyPassiveAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH) + .ability(AbilityId.STURDY); + }); + + // Contains logic shared by both Transform and Impostor (for brevity) + describe("Phases - PokemonTransformPhase", async () => { + it("should copy target's species, ability, gender, all stats except HP, all stat stages, moveset and types", async () => { + await game.classicMode.startBattle([SpeciesId.DITTO]); + + const ditto = game.field.getPlayerPokemon(); + const mew = game.field.getEnemyPokemon(); + + mew.setStatStage(Stat.ATK, 4); + + game.move.use(MoveId.SPLASH); + game.scene.phaseManager.unshiftNew("PokemonTransformPhase", ditto.getBattlerIndex(), mew.getBattlerIndex()); + await game.toEndOfTurn(); + + expect(ditto.isTransformed()).toBe(true); + expect(ditto.getSpeciesForm().speciesId).toBe(mew.getSpeciesForm().speciesId); + expect(ditto.getAbility()).toBe(mew.getAbility()); + expect(ditto.getGender()).toBe(mew.getGender()); + + const playerStats = ditto.getStats(false); + const enemyStats = mew.getStats(false); + // HP stays the same; all other stats should carry over + expect(playerStats[0]).not.toBe(enemyStats[0]); + expect(playerStats.slice(1)).toEqual(enemyStats.slice(1)); + + // Stat stages/moveset IDs + expect(ditto.getStatStages()).toEqual(mew.getStatStages()); + + expect(ditto.getMoveset().map(m => m.moveId)).toEqual(ditto.getMoveset().map(m => m.moveId)); + + expect(ditto.getTypes()).toEqual(mew.getTypes()); + }); + + // TODO: This is not implemented + it.todo("should copy the target's original typing if target is typeless", async () => { + game.override.enemySpecies(SpeciesId.MAGMAR); + await game.classicMode.startBattle([SpeciesId.DITTO]); + + const ditto = game.field.getPlayerPokemon(); + const magmar = game.field.getEnemyPokemon(); + + game.move.use(MoveId.TRANSFORM); + await game.move.forceEnemyMove(MoveId.BURN_UP); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toEndOfTurn(); + + expect(magmar.getTypes()).toEqual([PokemonType.UNKNOWN]); + expect(ditto.getTypes()).toEqual([PokemonType.FIRE]); + }); + + it("should not consider the target's Tera Type when copying types", async () => { + game.override.enemySpecies(SpeciesId.MAGMAR); + await game.classicMode.startBattle([SpeciesId.DITTO]); + + const ditto = game.field.getPlayerPokemon(); + const magmar = game.field.getEnemyPokemon(); + magmar.isTerastallized = true; + magmar.teraType = PokemonType.DARK; + + game.move.use(MoveId.TRANSFORM); + await game.toEndOfTurn(); + + expect(ditto.getTypes(true)).toEqual([PokemonType.FIRE]); + }); + + // TODO: This is not currently implemented + it.todo("should copy volatile status effects", async () => { + await game.classicMode.startBattle([SpeciesId.DITTO]); + + const ditto = game.field.getPlayerPokemon(); + const mew = game.field.getEnemyPokemon(); + mew.addTag(BattlerTagType.SEEDED, 0, MoveId.LEECH_SEED, ditto.id); + mew.addTag(BattlerTagType.CONFUSED, 4, MoveId.AXE_KICK, ditto.id); + + game.move.use(MoveId.TRANSFORM); + await game.toEndOfTurn(); + + expect(ditto.getTag(BattlerTagType.SEEDED)).toBeDefined(); + expect(ditto.getTag(BattlerTagType.CONFUSED)).toBeDefined(); + }); + + // TODO: This is not implemented + it.todo("should copy the target's rage fist hit count"); + + it("should not copy friendship, held items, nickname, level or non-volatile status effects", async () => { + game.override.enemyHeldItems([{ name: "BERRY", count: 1, type: BerryType.SITRUS }]); + await game.classicMode.startBattle([SpeciesId.DITTO]); + + const ditto = game.field.getPlayerPokemon(); + const mew = game.field.getEnemyPokemon(); + + mew.status = new Status(StatusEffect.POISON); + mew.friendship = 255; + mew.nickname = btoa(unescape(encodeURIComponent("Pink Furry Cat Thing"))); + + game.move.use(MoveId.TRANSFORM); + await game.toEndOfTurn(); + + expect(ditto.status?.effect).toBeUndefined(); + expect(ditto.getNameToRender()).not.toBe(mew.getNameToRender()); + expect(ditto.level).not.toBe(mew.level); + expect(ditto.friendship).not.toBe(mew.friendship); + expect(ditto.getHeldItems()).not.toEqual(mew.getHeldItems()); + }); + + it("should copy in-battle overridden stats", async () => { + await game.classicMode.startBattle([SpeciesId.DITTO]); + + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); + + const oldAtk = player.getStat(Stat.ATK); + const avgAtk = Math.floor((player.getStat(Stat.ATK, false) + enemy.getStat(Stat.ATK, false)) / 2); + + game.move.use(MoveId.TRANSFORM); + await game.move.forceEnemyMove(MoveId.POWER_SPLIT); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.toEndOfTurn(); + + expect(player.getStat(Stat.ATK, false)).toBe(avgAtk); + expect(enemy.getStat(Stat.ATK, false)).toBe(avgAtk); + expect(avgAtk).not.toBe(oldAtk); + }); + + it("should set each move's pp to a maximum of 5 without affecting PP ups", async () => { + game.override.enemyMoveset([MoveId.SWORDS_DANCE, MoveId.GROWL, MoveId.SKETCH, MoveId.RECOVER]); + await game.classicMode.startBattle([SpeciesId.DITTO]); + + const player = game.field.getPlayerPokemon(); + + game.move.use(MoveId.TRANSFORM); + await game.toEndOfTurn(); + + player.getMoveset().forEach(move => { + // Should set correct maximum PP without touching `ppUp` + if (move) { + if (move.moveId === MoveId.SKETCH) { + expect(move.getMovePp()).toBe(1); + } else { + expect(move.getMovePp()).toBe(5); + } + expect(move.ppUp).toBe(0); + } + }); + }); + + it("should activate its ability if it copies one that activates on summon", async () => { + game.override.enemyAbility(AbilityId.INTIMIDATE); + await game.classicMode.startBattle([SpeciesId.DITTO]); + + game.move.use(MoveId.TRANSFORM); + game.phaseInterceptor.clearLogs(); + await game.toEndOfTurn(); + + expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1); + expect(game.phaseInterceptor.log).toContain("StatStageChangePhase"); + }); + + it("should persist transformed attributes across reloads", async () => { + await game.classicMode.startBattle([SpeciesId.DITTO]); + + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); + + game.move.use(MoveId.TRANSFORM); + await game.move.forceEnemyMove(MoveId.MEMENTO); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextWave(); + + expect(game.scene.phaseManager.getCurrentPhase()?.phaseName).toBe("CommandPhase"); + expect(game.scene.currentBattle.waveIndex).toBe(2); + + await game.reload.reloadSession(); + + const playerReloaded = game.field.getPlayerPokemon(); + const playerMoveset = player.getMoveset(); + + expect(playerReloaded.getSpeciesForm().speciesId).toBe(enemy.getSpeciesForm().speciesId); + expect(playerReloaded.getAbility()).toBe(enemy.getAbility()); + expect(playerReloaded.getGender()).toBe(enemy.getGender()); + + expect(playerMoveset.map(m => m.moveId)).toEqual([MoveId.MEMENTO]); + }); + + it("should stay transformed with the correct form after reload", async () => { + game.override.enemySpecies(SpeciesId.DARMANITAN); + await game.classicMode.startBattle([SpeciesId.DITTO]); + + const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); + + // change form + enemy.species.formIndex = 1; + + game.move.use(MoveId.TRANSFORM); + await game.move.forceEnemyMove(MoveId.MEMENTO); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.toNextWave(); + + expect(game.scene.phaseManager.getCurrentPhase()?.phaseName).toBe("CommandPhase"); + expect(game.scene.currentBattle.waveIndex).toBe(2); + + expect(player.getSpeciesForm().speciesId).toBe(enemy.getSpeciesForm().speciesId); + expect(player.getSpeciesForm().formIndex).toBe(enemy.getSpeciesForm().formIndex); + + await game.reload.reloadSession(); + + const playerReloaded = game.field.getPlayerPokemon(); + expect(playerReloaded.getSpeciesForm().speciesId).toBe(enemy.getSpeciesForm().speciesId); + expect(playerReloaded.getSpeciesForm().formIndex).toBe(enemy.getSpeciesForm().formIndex); + }); + }); + + describe("Moves - Transform", () => { + it.each<{ cause: string; callback: (p: Pokemon) => void; player?: boolean }>([ + { + cause: "user is fused", + callback: p => vi.spyOn(p, "isFusion").mockReturnValue(true), + }, + { + cause: "target is fused", + callback: p => vi.spyOn(p, "isFusion").mockReturnValue(true), + player: false, + }, + { + cause: "user is transformed", + callback: p => vi.spyOn(p, "isTransformed").mockReturnValue(true), + }, + { + cause: "target is transformed", + callback: p => vi.spyOn(p, "isTransformed").mockReturnValue(true), + player: false, + }, + { + cause: "user has illusion", + callback: p => p.setIllusion(game.scene.getEnemyParty()[1]), + }, + { + cause: "target has illusion", + callback: p => p.setIllusion(game.scene.getEnemyParty()[1]), + player: false, + }, + { + cause: "target is behind a substitute", + callback: p => p.addTag(BattlerTagType.SUBSTITUTE, 1, MoveId.SUBSTITUTE, p.id), + player: false, + }, + ])("should fail if $cause", async ({ callback, player = true }) => { + game.override.battleType(BattleType.TRAINER); // ensures 2 enemy pokemon for illusion + await game.classicMode.startBattle([SpeciesId.DITTO, SpeciesId.ABOMASNOW]); + + callback(player ? game.field.getPlayerPokemon() : game.field.getEnemyPokemon()); + + game.move.use(MoveId.TRANSFORM); + await game.toEndOfTurn(); + + const ditto = game.field.getPlayerPokemon(); + expect(ditto.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + expect(game.phaseInterceptor.log).not.toContain("PokemonTransformPhase"); + }); + }); + + describe("Abilities - Imposter", () => { + beforeEach(async () => { + game.override.ability(AbilityId.NONE); + // Mock ability index to always be HA (ensuring Ditto has Imposter and nobody else). + ( + vi.spyOn(Pokemon.prototype as any, "generateAbilityIndex") as MockInstance< + (typeof Pokemon.prototype)["generateAbilityIndex"] + > + ).mockReturnValue(3); + }); + + it.each<{ name: string; callback: (p: EnemyPokemon) => void }>([ + { + name: "opponents with substitutes", + callback: p => p.addTag(BattlerTagType.SUBSTITUTE, 1, MoveId.SUBSTITUTE, p.id), + }, + { name: "fused opponents", callback: p => vi.spyOn(p, "isFusion").mockReturnValue(true) }, + { + name: "opponents with illusions", + callback: p => p.setIllusion(game.scene.getEnemyParty()[1]), // doesn't really matter what the illusion is, merely that it exists + }, + ])("should ignore $name during target selection", async ({ callback }) => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.GYARADOS, SpeciesId.MILOTIC, SpeciesId.DITTO]); + + const ditto = game.scene.getPlayerParty()[2]; + + const [enemy1, enemy2] = game.scene.getEnemyField(); + // Override enemy 1 to be a fusion/illusion + callback(enemy1); + + expect(ditto.canTransformInto(enemy1)).toBe(false); + expect(ditto.canTransformInto(enemy2)).toBe(true); + + // Switch out to Ditto + game.doSwitchPokemon(2); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.toEndOfTurn(); + + expect(ditto.isActive()).toBe(true); + expect(ditto.isTransformed()).toBe(true); + expect(ditto.getSpeciesForm().speciesId).toBe(enemy2.getSpeciesForm().speciesId); + expect(game.phaseInterceptor.log).toContain("ShowAbilityPhase"); + expect(game.phaseInterceptor.log).toContain("PokemonTransformPhase"); + }); + + it("should not activate if both opponents are fused or have illusions", async () => { + game.override.battleStyle("double"); + await game.classicMode.startBattle([SpeciesId.GYARADOS, SpeciesId.MILOTIC, SpeciesId.DITTO]); + + const [gyarados, , ditto] = game.scene.getPlayerParty(); + const [enemy1, enemy2] = game.scene.getEnemyParty(); + // Override enemy 1 to be a fusion & enemy 2 to have illusion + vi.spyOn(enemy1, "isFusion").mockReturnValue(true); + enemy2.setIllusion(gyarados); + + expect(ditto.canTransformInto(enemy1)).toBe(false); + expect(ditto.canTransformInto(enemy2)).toBe(false); + + // Switch out to Ditto + game.doSwitchPokemon(2); + game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2); + await game.toEndOfTurn(); + + expect(ditto.isActive()).toBe(true); + expect(ditto.isTransformed()).toBe(false); + expect(ditto.getSpeciesForm().speciesId).toBe(SpeciesId.DITTO); + expect(game.phaseInterceptor.log).not.toContain("ShowAbilityPhase"); + expect(game.phaseInterceptor.log).not.toContain("PokemonTransformPhase"); + }); + }); +}); diff --git a/test/moves/transform.test.ts b/test/moves/transform.test.ts deleted file mode 100644 index 4fbaf0136ab..00000000000 --- a/test/moves/transform.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import Phaser from "phaser"; -import GameManager from "#test/testUtils/gameManager"; -import { SpeciesId } from "#enums/species-id"; -import { TurnEndPhase } from "#app/phases/turn-end-phase"; -import { MoveId } from "#enums/move-id"; -import { Stat, EFFECTIVE_STATS } from "#enums/stat"; -import { AbilityId } from "#enums/ability-id"; -import { BattlerIndex } from "#enums/battler-index"; - -// TODO: Add more tests once Transform is fully implemented -describe("Moves - Transform", () => { - 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 - .battleStyle("single") - .enemySpecies(SpeciesId.MEW) - .enemyLevel(200) - .enemyAbility(AbilityId.BEAST_BOOST) - .enemyPassiveAbility(AbilityId.BALL_FETCH) - .enemyMoveset(MoveId.SPLASH) - .ability(AbilityId.INTIMIDATE) - .moveset([MoveId.TRANSFORM]); - }); - - it("should copy species, ability, gender, all stats except HP, all stat stages, moveset, and types of target", async () => { - await game.classicMode.startBattle([SpeciesId.DITTO]); - - game.move.select(MoveId.TRANSFORM); - await game.phaseInterceptor.to(TurnEndPhase); - - const player = game.scene.getPlayerPokemon()!; - const enemy = game.scene.getEnemyPokemon()!; - - expect(player.getSpeciesForm().speciesId).toBe(enemy.getSpeciesForm().speciesId); - expect(player.getAbility()).toBe(enemy.getAbility()); - expect(player.getGender()).toBe(enemy.getGender()); - - // copies all stats except hp - expect(player.getStat(Stat.HP, false)).not.toBe(enemy.getStat(Stat.HP)); - for (const s of EFFECTIVE_STATS) { - expect(player.getStat(s, false)).toBe(enemy.getStat(s, false)); - } - - expect(player.getStatStages()).toEqual(enemy.getStatStages()); - - // move IDs are equal - expect(player.getMoveset().map(m => m.moveId)).toEqual(enemy.getMoveset().map(m => m.moveId)); - - expect(player.getTypes()).toEqual(enemy.getTypes()); - }); - - it("should copy in-battle overridden stats", async () => { - game.override.enemyMoveset([MoveId.POWER_SPLIT]); - - await game.classicMode.startBattle([SpeciesId.DITTO]); - - const player = game.scene.getPlayerPokemon()!; - const enemy = game.scene.getEnemyPokemon()!; - - const avgAtk = Math.floor((player.getStat(Stat.ATK, false) + enemy.getStat(Stat.ATK, false)) / 2); - const avgSpAtk = Math.floor((player.getStat(Stat.SPATK, false) + enemy.getStat(Stat.SPATK, false)) / 2); - - game.move.select(MoveId.TRANSFORM); - await game.phaseInterceptor.to(TurnEndPhase); - - expect(player.getStat(Stat.ATK, false)).toBe(avgAtk); - expect(enemy.getStat(Stat.ATK, false)).toBe(avgAtk); - - expect(player.getStat(Stat.SPATK, false)).toBe(avgSpAtk); - expect(enemy.getStat(Stat.SPATK, false)).toBe(avgSpAtk); - }); - - it("should set each move's pp to a maximum of 5", async () => { - game.override.enemyMoveset([MoveId.SWORDS_DANCE, MoveId.GROWL, MoveId.SKETCH, MoveId.RECOVER]); - - await game.classicMode.startBattle([SpeciesId.DITTO]); - const player = game.scene.getPlayerPokemon()!; - - game.move.select(MoveId.TRANSFORM); - await game.phaseInterceptor.to(TurnEndPhase); - - player.getMoveset().forEach(move => { - // Should set correct maximum PP without touching `ppUp` - if (move) { - if (move.moveId === MoveId.SKETCH) { - expect(move.getMovePp()).toBe(1); - } else { - expect(move.getMovePp()).toBe(5); - } - expect(move.ppUp).toBe(0); - } - }); - }); - - it("should activate its ability if it copies one that activates on summon", async () => { - game.override.enemyAbility(AbilityId.INTIMIDATE).ability(AbilityId.BALL_FETCH); - - await game.classicMode.startBattle([SpeciesId.DITTO]); - game.move.select(MoveId.TRANSFORM); - - await game.phaseInterceptor.to("BerryPhase"); - - expect(game.scene.getEnemyPokemon()?.getStatStage(Stat.ATK)).toBe(-1); - }); - - it("should persist transformed attributes across reloads", async () => { - game.override.enemyMoveset([]).moveset([]); - - await game.classicMode.startBattle([SpeciesId.DITTO]); - - const player = game.scene.getPlayerPokemon()!; - const enemy = game.scene.getEnemyPokemon()!; - - game.move.changeMoveset(player, MoveId.TRANSFORM); - game.move.changeMoveset(enemy, MoveId.MEMENTO); - - game.move.select(MoveId.TRANSFORM); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.toNextWave(); - - expect(game.scene.phaseManager.getCurrentPhase()?.constructor.name).toBe("CommandPhase"); - expect(game.scene.currentBattle.waveIndex).toBe(2); - - await game.reload.reloadSession(); - - const playerReloaded = game.scene.getPlayerPokemon()!; - const playerMoveset = player.getMoveset(); - - expect(playerReloaded.getSpeciesForm().speciesId).toBe(enemy.getSpeciesForm().speciesId); - expect(playerReloaded.getAbility()).toBe(enemy.getAbility()); - expect(playerReloaded.getGender()).toBe(enemy.getGender()); - - expect(playerReloaded.getStat(Stat.HP, false)).not.toBe(enemy.getStat(Stat.HP)); - for (const s of EFFECTIVE_STATS) { - expect(playerReloaded.getStat(s, false)).toBe(enemy.getStat(s, false)); - } - - expect(playerMoveset.length).toEqual(1); - expect(playerMoveset[0]?.moveId).toEqual(MoveId.MEMENTO); - }); - - it("should stay transformed with the correct form after reload", async () => { - game.override.enemyMoveset([]).moveset([]).enemySpecies(SpeciesId.DARMANITAN); - - await game.classicMode.startBattle([SpeciesId.DITTO]); - - const player = game.scene.getPlayerPokemon()!; - const enemy = game.scene.getEnemyPokemon()!; - - // change form - enemy.species.forms[1]; - enemy.species.formIndex = 1; - - game.move.changeMoveset(player, MoveId.TRANSFORM); - game.move.changeMoveset(enemy, MoveId.MEMENTO); - - game.move.select(MoveId.TRANSFORM); - await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.toNextWave(); - - expect(game.scene.phaseManager.getCurrentPhase()?.constructor.name).toBe("CommandPhase"); - expect(game.scene.currentBattle.waveIndex).toBe(2); - - await game.reload.reloadSession(); - - const playerReloaded = game.scene.getPlayerPokemon()!; - - expect(playerReloaded.getSpeciesForm().speciesId).toBe(enemy.getSpeciesForm().speciesId); - expect(playerReloaded.getSpeciesForm().formIndex).toBe(enemy.getSpeciesForm().formIndex); - }); -}); diff --git a/test/testUtils/phaseInterceptor.ts b/test/testUtils/phaseInterceptor.ts index 9d046fc85ba..415ef02153c 100644 --- a/test/testUtils/phaseInterceptor.ts +++ b/test/testUtils/phaseInterceptor.ts @@ -64,6 +64,7 @@ import { PostGameOverPhase } from "#app/phases/post-game-over-phase"; import { RevivalBlessingPhase } from "#app/phases/revival-blessing-phase"; import type { PhaseClass, PhaseString } from "#app/@types/phase-types"; +import { PokemonTransformPhase } from "#app/phases/pokemon-transform-phase"; export interface PromptHandler { phaseTarget?: string; @@ -142,6 +143,7 @@ export default class PhaseInterceptor { [LevelCapPhase, this.startPhase], [AttemptRunPhase, this.startPhase], [SelectBiomePhase, this.startPhase], + [PokemonTransformPhase, this.startPhase], [MysteryEncounterPhase, this.startPhase], [MysteryEncounterOptionSelectedPhase, this.startPhase], [MysteryEncounterBattlePhase, this.startPhase], From 57d333807e36382f35afab809a083958d625014d Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sat, 21 Jun 2025 14:35:35 -0400 Subject: [PATCH 3/3] Fixed comment --- src/data/abilities/ability.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 6346a7c1d26..0feabb42fdd 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -8382,7 +8382,7 @@ export function initAbilities() { new Ability(AbilityId.IMPOSTER, 5) .attr(PostSummonTransformAbAttr) .uncopiable() - .edgeCase(), // Should copy rage fist hit count + .edgeCase(), // Should copy rage fist hit count, etc (see Transform edge case for full list) new Ability(AbilityId.INFILTRATOR, 5) .attr(InfiltratorAbAttr) .partial(), // does not bypass Mist