From 9e892597ea3fcb26efe033b752e0ef07b4985c59 Mon Sep 17 00:00:00 2001 From: jnotsknab Date: Wed, 18 Jun 2025 16:55:31 -0500 Subject: [PATCH 01/14] Fixed Transform and Imposter Bug --- src/data/abilities/ability.ts | 7 ++++++- src/data/battler-tags.ts | 11 +++++++++++ src/data/moves/move.ts | 1 - src/enums/battler-tag-type.ts | 1 + src/phases/move-phase.ts | 10 ++++++++++ src/phases/pokemon-transform-phase.ts | 2 ++ 6 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 70195d6a152..1e54c65f800 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -3909,8 +3909,13 @@ export class PostSummonTransformAbAttr extends PostSummonAbAttr { override canApplyPostSummon(pokemon: Pokemon, _passive: boolean, simulated: boolean, _args: any[]): boolean { const targets = pokemon.getOpponents(); const target = this.getTarget(targets); + const user = pokemon; - if (target.summonData.illusion) { + if (user?.getTag(BattlerTagType.TRANSFORM) || target?.getTag(BattlerTagType.TRANSFORM)) { + return false; + } + + if (target?.summonData?.illusion) { return false; } diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 8405fd1dd4d..d49061a97f9 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -3221,6 +3221,15 @@ export class ImprisonTag extends MoveRestrictionBattlerTag { } } +export class TransformTag extends BattlerTag { + constructor(sourceId: number) { + super(BattlerTagType.TRANSFORM, BattlerTagLapseType.MOVE, Number.MAX_SAFE_INTEGER, MoveId.TRANSFORM, sourceId); + } + override onAdd(pokemon: Pokemon): void { + super.onAdd(pokemon); + } +} + /** * Battler Tag that applies the effects of Syrup Bomb to the target Pokemon. * For three turns, starting from the turn of hit, at the end of each turn, the target Pokemon's speed will decrease by 1. @@ -3629,6 +3638,8 @@ export function getBattlerTag( return new TauntTag(); case BattlerTagType.IMPRISON: return new ImprisonTag(sourceId); + case BattlerTagType.TRANSFORM: + return new TransformTag(sourceId); case BattlerTagType.SYRUP_BOMB: return new SyrupBombTag(sourceId); case BattlerTagType.TELEKINESIS: diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index f61e8debc9f..02a6f957f3e 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -7599,7 +7599,6 @@ export class TransformAttr extends MoveEffectAttr { globalScene.phaseManager.unshiftNew("PokemonTransformPhase", user.getBattlerIndex(), target.getBattlerIndex()); - globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:transformedIntoTarget", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target) })); return true; } diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index 719b08c5b81..a19d81ba659 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -85,6 +85,7 @@ export enum BattlerTagType { HEAL_BLOCK = "HEAL_BLOCK", TORMENT = "TORMENT", TAUNT = "TAUNT", + TRANSFORM = "TRANSFORM", IMPRISON = "IMPRISON", SYRUP_BOMB = "SYRUP_BOMB", ELECTRIFIED = "ELECTRIFIED", diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 41a1042387b..997fa5a4aa0 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -339,6 +339,16 @@ export class MovePhase extends BattlePhase { this.showMoveText(); } + if ( + this.move.getMove().id === MoveId.TRANSFORM && + (targets[0].getTag(BattlerTagType.TRANSFORM) || this.pokemon.getTag(BattlerTagType.TRANSFORM)) + ) { + this.showMoveText(); + this.showFailedText(); + this.end(); + return; + } + // Clear out any two turn moves once they've been used. // TODO: Refactor move queues and remove this assignment; // Move queues should be handled by the calling `CommandPhase` or a manager for it diff --git a/src/phases/pokemon-transform-phase.ts b/src/phases/pokemon-transform-phase.ts index 938915309d9..eacc4f58d5f 100644 --- a/src/phases/pokemon-transform-phase.ts +++ b/src/phases/pokemon-transform-phase.ts @@ -39,6 +39,8 @@ export class PokemonTransformPhase extends PokemonPhase { // Power Trick's effect is removed after using Transform user.removeTag(BattlerTagType.POWER_TRICK); + //Begin tracking Pokemon as transformed + user.addTag(BattlerTagType.TRANSFORM); // Copy all stats (except HP) for (const s of EFFECTIVE_STATS) { From 0a2e6e1cec738c9032899cf703f46c66821e27f0 Mon Sep 17 00:00:00 2001 From: jnotsknab Date: Wed, 18 Jun 2025 18:03:02 -0500 Subject: [PATCH 02/14] Updated Comments --- src/data/battler-tags.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index d49061a97f9..2b1f47d9757 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -3221,10 +3221,20 @@ export class ImprisonTag extends MoveRestrictionBattlerTag { } } +/** + * Battler Tag indicating that a Pokémon has used {@linkcode MoveId.TRANSFORM} + * + * The tag allows us to prevent certain actions such as using Transform on a Pokemon which has already transformed, + * and is used to ensure correct battle behavior after transformation. + */ export class TransformTag extends BattlerTag { constructor(sourceId: number) { super(BattlerTagType.TRANSFORM, BattlerTagLapseType.MOVE, Number.MAX_SAFE_INTEGER, MoveId.TRANSFORM, sourceId); } + /** + * Adds the Transform battler tag to the Pokemon transforming. + * @param pokemon - The {@linkcode Pokemon} transforming + */ override onAdd(pokemon: Pokemon): void { super.onAdd(pokemon); } From f80211406fce03a653006293da63e0b6c7f3f904 Mon Sep 17 00:00:00 2001 From: jnotsknab Date: Wed, 18 Jun 2025 18:15:53 -0500 Subject: [PATCH 03/14] Revised Comments --- src/data/abilities/ability.ts | 1 + src/phases/move-phase.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 1e54c65f800..2c14b6ec795 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -3911,6 +3911,7 @@ export class PostSummonTransformAbAttr extends PostSummonAbAttr { const target = this.getTarget(targets); const user = pokemon; + //Prevents Imposter from triggering on a transformed target or if the user is already transformed if (user?.getTag(BattlerTagType.TRANSFORM) || target?.getTag(BattlerTagType.TRANSFORM)) { return false; } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 997fa5a4aa0..0feb3032b27 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -340,6 +340,7 @@ export class MovePhase extends BattlePhase { } if ( + //Prevent using Transform if either the user or target is already transformed this.move.getMove().id === MoveId.TRANSFORM && (targets[0].getTag(BattlerTagType.TRANSFORM) || this.pokemon.getTag(BattlerTagType.TRANSFORM)) ) { From c842f198f12ca54741840936584efae6730becbb Mon Sep 17 00:00:00 2001 From: jnotsknab Date: Thu, 19 Jun 2025 19:51:14 -0500 Subject: [PATCH 04/14] Revised Changes on how tranformed pokemon are referenced to align with reviewer's suggestions --- src/data/abilities/ability.ts | 3 +-- src/data/battler-tags.ts | 21 --------------------- src/field/pokemon.ts | 4 ++++ src/phases/move-phase.ts | 2 +- 4 files changed, 6 insertions(+), 24 deletions(-) diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 2c14b6ec795..17c1e02d773 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -3909,10 +3909,9 @@ export class PostSummonTransformAbAttr extends PostSummonAbAttr { override canApplyPostSummon(pokemon: Pokemon, _passive: boolean, simulated: boolean, _args: any[]): boolean { const targets = pokemon.getOpponents(); const target = this.getTarget(targets); - const user = pokemon; //Prevents Imposter from triggering on a transformed target or if the user is already transformed - if (user?.getTag(BattlerTagType.TRANSFORM) || target?.getTag(BattlerTagType.TRANSFORM)) { + if (pokemon?.isTransformed() || target?.isTransformed()) { return false; } diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 2b1f47d9757..8405fd1dd4d 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -3221,25 +3221,6 @@ export class ImprisonTag extends MoveRestrictionBattlerTag { } } -/** - * Battler Tag indicating that a Pokémon has used {@linkcode MoveId.TRANSFORM} - * - * The tag allows us to prevent certain actions such as using Transform on a Pokemon which has already transformed, - * and is used to ensure correct battle behavior after transformation. - */ -export class TransformTag extends BattlerTag { - constructor(sourceId: number) { - super(BattlerTagType.TRANSFORM, BattlerTagLapseType.MOVE, Number.MAX_SAFE_INTEGER, MoveId.TRANSFORM, sourceId); - } - /** - * Adds the Transform battler tag to the Pokemon transforming. - * @param pokemon - The {@linkcode Pokemon} transforming - */ - override onAdd(pokemon: Pokemon): void { - super.onAdd(pokemon); - } -} - /** * Battler Tag that applies the effects of Syrup Bomb to the target Pokemon. * For three turns, starting from the turn of hit, at the end of each turn, the target Pokemon's speed will decrease by 1. @@ -3648,8 +3629,6 @@ export function getBattlerTag( return new TauntTag(); case BattlerTagType.IMPRISON: return new ImprisonTag(sourceId); - case BattlerTagType.TRANSFORM: - return new TransformTag(sourceId); case BattlerTagType.SYRUP_BOMB: return new SyrupBombTag(sourceId); case BattlerTagType.TELEKINESIS: diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index e9cc4f70d70..deeffd736e8 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1062,6 +1062,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return species; } + isTransformed(): boolean { + return this.summonData.speciesForm !== null; + } + /** * @param {boolean} useIllusion - Whether we want the fusionSpeciesForm of the illusion or not. */ diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 0feb3032b27..df2697f17a8 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -342,7 +342,7 @@ export class MovePhase extends BattlePhase { if ( //Prevent using Transform if either the user or target is already transformed this.move.getMove().id === MoveId.TRANSFORM && - (targets[0].getTag(BattlerTagType.TRANSFORM) || this.pokemon.getTag(BattlerTagType.TRANSFORM)) + (targets[0].isTransformed() || this.pokemon.isTransformed()) ) { this.showMoveText(); this.showFailedText(); From 53e67dbec13acb17ec014ad8e4c90bbe51cfd189 Mon Sep 17 00:00:00 2001 From: jnotsknab Date: Thu, 19 Jun 2025 19:56:14 -0500 Subject: [PATCH 05/14] Removed unused BattlerTag type for transform --- src/enums/battler-tag-type.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index a19d81ba659..719b08c5b81 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -85,7 +85,6 @@ export enum BattlerTagType { HEAL_BLOCK = "HEAL_BLOCK", TORMENT = "TORMENT", TAUNT = "TAUNT", - TRANSFORM = "TRANSFORM", IMPRISON = "IMPRISON", SYRUP_BOMB = "SYRUP_BOMB", ELECTRIFIED = "ELECTRIFIED", From 9b305ca65d630a992bbe947dc61ab2bd07326bf4 Mon Sep 17 00:00:00 2001 From: jnotsknab Date: Thu, 19 Jun 2025 19:58:44 -0500 Subject: [PATCH 06/14] Removed addition of the transform tag to a pokemon in the PokemonTransformPhase --- src/phases/pokemon-transform-phase.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/phases/pokemon-transform-phase.ts b/src/phases/pokemon-transform-phase.ts index eacc4f58d5f..938915309d9 100644 --- a/src/phases/pokemon-transform-phase.ts +++ b/src/phases/pokemon-transform-phase.ts @@ -39,8 +39,6 @@ export class PokemonTransformPhase extends PokemonPhase { // Power Trick's effect is removed after using Transform user.removeTag(BattlerTagType.POWER_TRICK); - //Begin tracking Pokemon as transformed - user.addTag(BattlerTagType.TRANSFORM); // Copy all stats (except HP) for (const s of EFFECTIVE_STATS) { From eab4f6e01f6b654015a0e00ff9de2d2c631e4eca Mon Sep 17 00:00:00 2001 From: jnotsknab Date: Fri, 20 Jun 2025 02:10:41 -0500 Subject: [PATCH 07/14] Moved the check for whether Transform can be used within the TransformAttr class as suggested and removed uneccesary moveId check --- src/data/moves/move.ts | 4 ++-- src/phases/move-phase.ts | 11 ----------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 7d3fc253da7..066114e6aca 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -7610,13 +7610,13 @@ export class SuppressAbilitiesIfActedAttr extends MoveEffectAttr { */ export class TransformAttr extends MoveEffectAttr { override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - if (!super.apply(user, target, move, args)) { + if (!super.apply(user, target, move, args) || (target.isTransformed() || user.isTransformed())) { + globalScene.phaseManager.queueMessage(i18next.t("battle:attackFailed")); return false; } globalScene.phaseManager.unshiftNew("PokemonTransformPhase", user.getBattlerIndex(), target.getBattlerIndex()); - return true; } } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 0deb1c6f470..2e94b085948 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -339,17 +339,6 @@ export class MovePhase extends BattlePhase { this.showMoveText(); } - if ( - //Prevent using Transform if either the user or target is already transformed - this.move.getMove().id === MoveId.TRANSFORM && - (targets[0].isTransformed() || this.pokemon.isTransformed()) - ) { - this.showMoveText(); - this.showFailedText(); - this.end(); - return; - } - // Clear out any two turn moves once they've been used. // TODO: Refactor move queues and remove this assignment; // Move queues should be handled by the calling `CommandPhase` or a manager for it From e3ebeb9471ffb1fadfae5bfb50f11fdeb08dbaea Mon Sep 17 00:00:00 2001 From: jnotsknab Date: Fri, 20 Jun 2025 02:32:13 -0500 Subject: [PATCH 08/14] Consolidated if statement regarding isTransformed --- src/data/abilities/ability.ts | 7 +------ src/overrides.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index bc9084ea7f2..0846d5602cc 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -3903,12 +3903,7 @@ export class PostSummonTransformAbAttr extends PostSummonAbAttr { const targets = pokemon.getOpponents(); const target = this.getTarget(targets); - //Prevents Imposter from triggering on a transformed target or if the user is already transformed - if (pokemon?.isTransformed() || target?.isTransformed()) { - return false; - } - - if (target?.summonData?.illusion) { + if (target?.summonData?.illusion || pokemon?.isTransformed() || target?.isTransformed()) { return false; } diff --git a/src/overrides.ts b/src/overrides.ts index b390b9fa70f..4f3630a67e8 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -43,7 +43,14 @@ import { WeatherType } from "#enums/weather-type"; * } * ``` */ -const overrides = {} satisfies Partial>; +const overrides = { + OPP_SPECIES_OVERRIDE: SpeciesId.DITTO, + OPP_MOVESET_OVERRIDE: MoveId.TRANSFORM, + OPP_ABILITY_OVERRIDE: AbilityId.IMPOSTER, + ABILITY_OVERRIDE: AbilityId.IMPOSTER, + STARTER_SPECIES_OVERRIDE: SpeciesId.MEW, + MOVESET_OVERRIDE: MoveId.TRANSFORM, +} satisfies Partial>; /** * If you need to add Overrides values for local testing do that inside {@linkcode overrides} From 5a1fbda44a36a5d3647c90b724c9adab94549635 Mon Sep 17 00:00:00 2001 From: jnotsknab Date: Fri, 20 Jun 2025 02:35:35 -0500 Subject: [PATCH 09/14] Removed overrides --- src/overrides.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/overrides.ts b/src/overrides.ts index 4f3630a67e8..b390b9fa70f 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -43,14 +43,7 @@ import { WeatherType } from "#enums/weather-type"; * } * ``` */ -const overrides = { - OPP_SPECIES_OVERRIDE: SpeciesId.DITTO, - OPP_MOVESET_OVERRIDE: MoveId.TRANSFORM, - OPP_ABILITY_OVERRIDE: AbilityId.IMPOSTER, - ABILITY_OVERRIDE: AbilityId.IMPOSTER, - STARTER_SPECIES_OVERRIDE: SpeciesId.MEW, - MOVESET_OVERRIDE: MoveId.TRANSFORM, -} satisfies Partial>; +const overrides = {} satisfies Partial>; /** * If you need to add Overrides values for local testing do that inside {@linkcode overrides} From d9d119aadaf37ad8731aee65ea38e2f7d4363c85 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sat, 21 Jun 2025 13:08:20 -0400 Subject: [PATCH 10/14] 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 11/14] 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 12/14] 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 From 0a2f83c4335ff81786c3e1237078e558f54c0186 Mon Sep 17 00:00:00 2001 From: jnotsknab Date: Mon, 30 Jun 2025 16:11:33 -0500 Subject: [PATCH 13/14] Updated override typo for unimplemented methods --- src/data/abilities/ability.ts | 6 +++--- src/field/pokemon.ts | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index afa75f50ddb..2a3788da792 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -3073,14 +3073,14 @@ export class PostSummonTransformAbAttr extends PostSummonAbAttr { return mon; } - override canApplyPostSummon(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _args: any[]): boolean { + override canApply({ pokemon }: AbAttrBaseParams): boolean { const target = this.getTarget(pokemon); + return !!target; } - override applyPostSummon(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _args: any[]): void { + override apply({ pokemon }: AbAttrBaseParams): void { globalScene.phaseManager.unshiftNew("PokemonTransformPhase", pokemon.getBattlerIndex(), this.targetIndex, true); - } } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index b1a6db201f4..15204a5812d 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1056,6 +1056,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return species; } + /** + * Getter function that returns whether this {@linkcode Pokemon} is currently transformed into another one + * (such as by the effects of {@linkcode MoveId.TRANSFORM} or {@linkcode AbilityId.IMPOSTER}. + * @returns Whether this Pokemon is currently transformed. + */ isTransformed(): boolean { return this.summonData.speciesForm !== null; } From b7a59a1c80b699a61e550bac868b499139880129 Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Wed, 2 Jul 2025 04:01:01 -0700 Subject: [PATCH 14/14] Remove redundant parentheses