[Bug][Ability] Fix change move type abilities (#5665)

* Make type changing moves change type after abilities but before ion deluge/electrify

* Create unified test file for galvanize, pixilate, and refrigerate

* Make type boost items like silk scarf affect the move after its type change

* Add tests for type boost item interaction

* Remove leftover log messages

* Update spies in type-change ability tests

* Add automated tests for normalize

* Fix test name injection for tera blast

* Add automated test for tera blast normalize interaction

* Restore pokemon as a type-only import in moves.ts

* Add aerilate to type changing tests

* Rename galvanize test file

* Fix utils import

* Apply suggestions from code review

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Remove unnecessary mockRestore

* Remove unnecessary nullish coalescing

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Update src/field/pokemon.ts

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

---------

Co-authored-by: Madmadness65 <59298170+Madmadness65@users.noreply.github.com>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
This commit is contained in:
Sirz Benjie 2025-05-01 21:41:57 -05:00 committed by GitHub
parent 8eeec9511b
commit 3af1bdbcff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 401 additions and 156 deletions

View File

@ -14,3 +14,6 @@ export const MAX_INT_ATTR_VALUE = 0x80000000;
export const CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES: [number, number] = [10, 180] as const;
/** The min and max waves for mystery encounters to spawn in challenge mode */
export const CHALLENGE_MODE_MYSTERY_ENCOUNTER_WAVES: [number, number] = [10, 180] as const;
/** The raw percentage power boost for type boost items*/
export const TYPE_BOOST_ITEM_BOOST_PERCENT = 20;

View File

@ -14,7 +14,6 @@ import {
SelfStatusMove,
VariablePowerAttr,
applyMoveAttrs,
VariableMoveTypeAttr,
RandomMovesetMoveAttr,
RandomMoveAttr,
NaturePowerAttr,
@ -73,6 +72,7 @@ import type { BattlerIndex } from "#app/battle";
import type Move from "#app/data/moves/move";
import type { ArenaTrapTag, SuppressAbilitiesTag } from "#app/data/arena-tag";
import { SelectBiomePhase } from "#app/phases/select-biome-phase";
import { noAbilityTypeOverrideMoves } from "../moves/invalid-moves";
export class BlockRecoilDamageAttr extends AbAttr {
constructor() {
@ -1240,12 +1240,39 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr {
super(false);
}
override canApplyPreAttack(pokemon: Pokemon, passive: boolean, simulated: boolean, defender: Pokemon | null, move: Move, args: any[]): boolean {
return (this.condition && this.condition(pokemon, defender, move)) ?? false;
/**
* Determine if the move type change attribute can be applied
*
* Can be applied if:
* - The ability's condition is met, e.g. pixilate only boosts normal moves,
* - The move is not forbidden from having its type changed by an ability, e.g. {@linkcode Moves.MULTI_ATTACK}
* - The user is not terastallized and using tera blast
* - The user is not a terastallized terapagos with tera stellar using tera starstorm
* @param pokemon - The pokemon that has the move type changing ability and is using the attacking move
* @param _passive - Unused
* @param _simulated - Unused
* @param _defender - The pokemon being attacked (unused)
* @param move - The move being used
* @param _args - args[0] holds the type that the move is changed to, args[1] holds the multiplier
* @returns whether the move type change attribute can be applied
*/
override canApplyPreAttack(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _defender: Pokemon | null, move: Move, _args: [NumberHolder?, NumberHolder?, ...any]): boolean {
return (!this.condition || this.condition(pokemon, _defender, move)) &&
!noAbilityTypeOverrideMoves.has(move.id) &&
(!pokemon.isTerastallized ||
(move.id !== Moves.TERA_BLAST &&
(move.id !== Moves.TERA_STARSTORM || pokemon.getTeraType() !== PokemonType.STELLAR || !pokemon.hasSpecies(Species.TERAPAGOS))));
}
// TODO: Decouple this into two attributes (type change / power boost)
override applyPreAttack(pokemon: Pokemon, passive: boolean, simulated: boolean, defender: Pokemon, move: Move, args: any[]): void {
/**
* @param pokemon - The pokemon that has the move type changing ability and is using the attacking move
* @param passive - Unused
* @param simulated - Unused
* @param defender - The pokemon being attacked (unused)
* @param move - The move being used
* @param args - args[0] holds the type that the move is changed to, args[1] holds the multiplier
*/
override applyPreAttack(pokemon: Pokemon, passive: boolean, simulated: boolean, defender: Pokemon, move: Move, args: [NumberHolder?, NumberHolder?, ...any]): void {
if (args[0] && args[0] instanceof NumberHolder) {
args[0].value = this.newType;
}
@ -6629,9 +6656,7 @@ export function initAbilities() {
.conditionalAttr(pokemon => pokemon.status ? pokemon.status.effect === StatusEffect.PARALYSIS : false, StatMultiplierAbAttr, Stat.SPD, 2)
.conditionalAttr(pokemon => !!pokemon.status || pokemon.hasAbility(Abilities.COMATOSE), StatMultiplierAbAttr, Stat.SPD, 1.5),
new Ability(Abilities.NORMALIZE, 4)
.attr(MoveTypeChangeAbAttr, PokemonType.NORMAL, 1.2, (user, target, move) => {
return ![ Moves.MULTI_ATTACK, Moves.REVELATION_DANCE, Moves.TERRAIN_PULSE, Moves.HIDDEN_POWER, Moves.WEATHER_BALL, Moves.NATURAL_GIFT, Moves.JUDGMENT, Moves.TECHNO_BLAST ].includes(move.id);
}),
.attr(MoveTypeChangeAbAttr, PokemonType.NORMAL, 1.2),
new Ability(Abilities.SNIPER, 4)
.attr(MultCritAbAttr, 1.5),
new Ability(Abilities.MAGIC_GUARD, 4)
@ -6896,7 +6921,7 @@ export function initAbilities() {
new Ability(Abilities.STRONG_JAW, 6)
.attr(MovePowerBoostAbAttr, (user, target, move) => move.hasFlag(MoveFlags.BITING_MOVE), 1.5),
new Ability(Abilities.REFRIGERATE, 6)
.attr(MoveTypeChangeAbAttr, PokemonType.ICE, 1.2, (user, target, move) => move.type === PokemonType.NORMAL && !move.hasAttr(VariableMoveTypeAttr)),
.attr(MoveTypeChangeAbAttr, PokemonType.ICE, 1.2, (user, target, move) => move.type === PokemonType.NORMAL),
new Ability(Abilities.SWEET_VEIL, 6)
.attr(UserFieldStatusEffectImmunityAbAttr, StatusEffect.SLEEP)
.attr(PostSummonUserFieldRemoveStatusEffectAbAttr, StatusEffect.SLEEP)
@ -6920,11 +6945,11 @@ export function initAbilities() {
new Ability(Abilities.TOUGH_CLAWS, 6)
.attr(MovePowerBoostAbAttr, (user, target, move) => move.hasFlag(MoveFlags.MAKES_CONTACT), 1.3),
new Ability(Abilities.PIXILATE, 6)
.attr(MoveTypeChangeAbAttr, PokemonType.FAIRY, 1.2, (user, target, move) => move.type === PokemonType.NORMAL && !move.hasAttr(VariableMoveTypeAttr)),
.attr(MoveTypeChangeAbAttr, PokemonType.FAIRY, 1.2, (user, target, move) => move.type === PokemonType.NORMAL),
new Ability(Abilities.GOOEY, 6)
.attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.hasFlag(MoveFlags.MAKES_CONTACT), Stat.SPD, -1, false),
new Ability(Abilities.AERILATE, 6)
.attr(MoveTypeChangeAbAttr, PokemonType.FLYING, 1.2, (user, target, move) => move.type === PokemonType.NORMAL && !move.hasAttr(VariableMoveTypeAttr)),
.attr(MoveTypeChangeAbAttr, PokemonType.FLYING, 1.2, (user, target, move) => move.type === PokemonType.NORMAL),
new Ability(Abilities.PARENTAL_BOND, 6)
.attr(AddSecondStrikeAbAttr, 0.25),
new Ability(Abilities.DARK_AURA, 6)
@ -7001,7 +7026,7 @@ export function initAbilities() {
new Ability(Abilities.TRIAGE, 7)
.attr(ChangeMovePriorityAbAttr, (pokemon, move) => move.hasFlag(MoveFlags.TRIAGE_MOVE), 3),
new Ability(Abilities.GALVANIZE, 7)
.attr(MoveTypeChangeAbAttr, PokemonType.ELECTRIC, 1.2, (user, target, move) => move.type === PokemonType.NORMAL && !move.hasAttr(VariableMoveTypeAttr)),
.attr(MoveTypeChangeAbAttr, PokemonType.ELECTRIC, 1.2, (_user, _target, move) => move.type === PokemonType.NORMAL),
new Ability(Abilities.SURGE_SURFER, 7)
.conditionalAttr(getTerrainCondition(TerrainType.ELECTRIC), StatMultiplierAbAttr, Stat.SPD, 2),
new Ability(Abilities.SCHOOLING, 7)

View File

@ -240,3 +240,18 @@ export const invalidMirrorMoveMoves: ReadonlySet<Moves> = new Set([
Moves.WATER_SPORT,
Moves.WIDE_GUARD,
]);
/** Set of moves that can never have their type overridden by an ability like Pixilate or Normalize
*
* Excludes tera blast and tera starstorm, as these are only conditionally forbidden
*/
export const noAbilityTypeOverrideMoves: ReadonlySet<Moves> = new Set([
Moves.WEATHER_BALL,
Moves.JUDGMENT,
Moves.REVELATION_DANCE,
Moves.MULTI_ATTACK,
Moves.TERRAIN_PULSE,
Moves.NATURAL_GIFT,
Moves.TECHNO_BLAST,
Moves.HIDDEN_POWER,
]);

View File

@ -810,8 +810,9 @@ export default class Move implements Localizable {
const power = new NumberHolder(this.power);
const typeChangeMovePowerMultiplier = new NumberHolder(1);
const typeChangeHolder = new NumberHolder(this.type);
applyPreAttackAbAttrs(MoveTypeChangeAbAttr, source, target, this, true, null, typeChangeMovePowerMultiplier);
applyPreAttackAbAttrs(MoveTypeChangeAbAttr, source, target, this, true, typeChangeHolder, typeChangeMovePowerMultiplier);
const sourceTeraType = source.getTeraType();
if (source.isTerastallized && sourceTeraType === this.type && power.value < 60 && this.priority <= 0 && !this.hasAttr(MultiHitAttr) && !globalScene.findModifier(m => m instanceof PokemonMultiHitModifier && m.pokemonId === source.id)) {
@ -841,7 +842,7 @@ export default class Move implements Localizable {
power.value *= typeChangeMovePowerMultiplier.value;
const typeBoost = source.findTag(t => t instanceof TypeBoostTag && t.boostedType === this.type) as TypeBoostTag;
const typeBoost = source.findTag(t => t instanceof TypeBoostTag && t.boostedType === typeChangeHolder.value) as TypeBoostTag;
if (typeBoost) {
power.value *= typeBoost.boostValue;
}
@ -849,8 +850,8 @@ export default class Move implements Localizable {
applyMoveAttrs(VariablePowerAttr, source, target, this, power);
if (!this.hasAttr(TypelessAttr)) {
globalScene.arena.applyTags(WeakenMoveTypeTag, simulated, this.type, power);
globalScene.applyModifiers(AttackTypeBoosterModifier, source.isPlayer(), source, this.type, power);
globalScene.arena.applyTags(WeakenMoveTypeTag, simulated, typeChangeHolder.value, power);
globalScene.applyModifiers(AttackTypeBoosterModifier, source.isPlayer(), source, typeChangeHolder.value, power);
}
if (source.getTag(HelpingHandTag)) {
@ -4826,7 +4827,12 @@ export class FormChangeItemTypeAttr extends VariableMoveTypeAttr {
return true;
}
return false;
// Force move to have its original typing if it changed
if (moveType.value === move.type) {
return false;
}
moveType.value = move.type
return true;
}
}
@ -4977,7 +4983,11 @@ export class WeatherBallTypeAttr extends VariableMoveTypeAttr {
moveType.value = PokemonType.ICE;
break;
default:
return false;
if (moveType.value === move.type) {
return false;
}
moveType.value = move.type;
break;
}
return true;
}
@ -5025,7 +5035,12 @@ export class TerrainPulseTypeAttr extends VariableMoveTypeAttr {
moveType.value = PokemonType.PSYCHIC;
break;
default:
return false;
if (moveType.value === move.type) {
return false;
}
// force move to have its original typing if it was changed
moveType.value = move.type;
break;
}
return true;
}

View File

@ -2591,9 +2591,15 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
null,
move,
simulated,
moveTypeHolder,
moveTypeHolder
);
// If the user is terastallized and the move is tera blast, or tera starstorm that is stellar type,
// then bypass the check for ion deluge and electrify
if (this.isTerastallized && (move.id === Moves.TERA_BLAST || move.id === Moves.TERA_STARSTORM && moveTypeHolder.value === PokemonType.STELLAR)) {
return moveTypeHolder.value as PokemonType;
}
globalScene.arena.applyTags(
ArenaTagType.ION_DELUGE,
simulated,

View File

@ -128,6 +128,7 @@ import { getStatKey, Stat, TEMP_BATTLE_STATS } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import i18next from "i18next";
import { timedEventManager } from "#app/global-event-manager";
import { TYPE_BOOST_ITEM_BOOST_PERCENT } from "#app/constants";
const outputModifierData = false;
const useMaxWeightForOutput = false;
@ -1329,7 +1330,7 @@ class AttackTypeBoosterModifierTypeGenerator extends ModifierTypeGenerator {
constructor() {
super((party: Pokemon[], pregenArgs?: any[]) => {
if (pregenArgs && pregenArgs.length === 1 && pregenArgs[0] in PokemonType) {
return new AttackTypeBoosterModifierType(pregenArgs[0] as PokemonType, 20);
return new AttackTypeBoosterModifierType(pregenArgs[0] as PokemonType, TYPE_BOOST_ITEM_BOOST_PERCENT);
}
const attackMoveTypes = party.flatMap(p =>
@ -1377,7 +1378,7 @@ class AttackTypeBoosterModifierTypeGenerator extends ModifierTypeGenerator {
weight += typeWeight;
}
return new AttackTypeBoosterModifierType(type!, 20);
return new AttackTypeBoosterModifierType(type!, TYPE_BOOST_ITEM_BOOST_PERCENT);
});
}
}

View File

@ -1479,7 +1479,8 @@ export class AttackTypeBoosterModifier extends PokemonHeldItemModifier {
return (
super.shouldApply(pokemon, moveType, movePower) &&
typeof moveType === "number" &&
movePower instanceof NumberHolder
movePower instanceof NumberHolder &&
this.moveType === moveType
);
}

View File

@ -1,131 +0,0 @@
import { BattlerIndex } from "#app/battle";
import { allMoves } from "#app/data/moves/move";
import { PokemonType } from "#enums/pokemon-type";
import { Abilities } from "#app/enums/abilities";
import { Moves } from "#app/enums/moves";
import { Species } from "#app/enums/species";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Abilities - Galvanize", () => {
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")
.startingLevel(100)
.ability(Abilities.GALVANIZE)
.moveset([Moves.TACKLE, Moves.REVELATION_DANCE, Moves.FURY_SWIPES])
.enemySpecies(Species.DUSCLOPS)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH)
.enemyLevel(100);
});
it("should change Normal-type attacks to Electric type and boost their power", async () => {
await game.classicMode.startBattle();
const playerPokemon = game.scene.getPlayerPokemon()!;
vi.spyOn(playerPokemon, "getMoveType");
const enemyPokemon = game.scene.getEnemyPokemon()!;
const spy = vi.spyOn(enemyPokemon, "getMoveEffectiveness");
const move = allMoves[Moves.TACKLE];
vi.spyOn(move, "calculateBattlePower");
game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to("BerryPhase", false);
expect(playerPokemon.getMoveType).toHaveLastReturnedWith(PokemonType.ELECTRIC);
expect(spy).toHaveReturnedWith(1);
expect(move.calculateBattlePower).toHaveReturnedWith(48);
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
spy.mockRestore();
});
it("should cause Normal-type attacks to activate Volt Absorb", async () => {
game.override.enemyAbility(Abilities.VOLT_ABSORB);
await game.classicMode.startBattle();
const playerPokemon = game.scene.getPlayerPokemon()!;
vi.spyOn(playerPokemon, "getMoveType");
const enemyPokemon = game.scene.getEnemyPokemon()!;
const spy = vi.spyOn(enemyPokemon, "getMoveEffectiveness");
enemyPokemon.hp = Math.floor(enemyPokemon.getMaxHp() * 0.8);
game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to("BerryPhase", false);
expect(playerPokemon.getMoveType).toHaveLastReturnedWith(PokemonType.ELECTRIC);
expect(spy).toHaveReturnedWith(0);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
});
it("should not change the type of variable-type moves", async () => {
game.override.enemySpecies(Species.MIGHTYENA);
await game.classicMode.startBattle([Species.ESPEON]);
const playerPokemon = game.scene.getPlayerPokemon()!;
vi.spyOn(playerPokemon, "getMoveType");
const enemyPokemon = game.scene.getEnemyPokemon()!;
const spy = vi.spyOn(enemyPokemon, "getMoveEffectiveness");
game.move.select(Moves.REVELATION_DANCE);
await game.phaseInterceptor.to("BerryPhase", false);
expect(playerPokemon.getMoveType).not.toHaveLastReturnedWith(PokemonType.ELECTRIC);
expect(spy).toHaveReturnedWith(0);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
});
it("should affect all hits of a Normal-type multi-hit move", async () => {
await game.classicMode.startBattle();
const playerPokemon = game.scene.getPlayerPokemon()!;
vi.spyOn(playerPokemon, "getMoveType");
const enemyPokemon = game.scene.getEnemyPokemon()!;
const spy = vi.spyOn(enemyPokemon, "getMoveEffectiveness");
game.move.select(Moves.FURY_SWIPES);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.move.forceHit();
await game.phaseInterceptor.to("MoveEffectPhase");
expect(playerPokemon.turnData.hitCount).toBeGreaterThan(1);
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
while (playerPokemon.turnData.hitsLeft > 0) {
const enemyStartingHp = enemyPokemon.hp;
await game.phaseInterceptor.to("MoveEffectPhase");
expect(playerPokemon.getMoveType).toHaveLastReturnedWith(PokemonType.ELECTRIC);
expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp);
}
expect(spy).not.toHaveReturnedWith(0);
});
});

View File

@ -0,0 +1,190 @@
import { BattlerIndex } from "#app/battle";
import { allMoves } from "#app/data/moves/move";
import { PokemonType } from "#enums/pokemon-type";
import { Abilities } from "#app/enums/abilities";
import { Moves } from "#app/enums/moves";
import { Species } from "#app/enums/species";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { TYPE_BOOST_ITEM_BOOST_PERCENT } from "#app/constants";
import { allAbilities } from "#app/data/data-lists";
import { MoveTypeChangeAbAttr } from "#app/data/abilities/ability";
import { toDmgValue } from "#app/utils/common";
/**
* Tests for abilities that change the type of normal moves to
* a different type and boost their power
*
* Includes
* - Aerialate
* - Galvanize
* - Pixilate
* - Refrigerate
*/
describe.each([
{ ab: Abilities.GALVANIZE, ab_name: "Galvanize", ty: PokemonType.ELECTRIC, tyName: "electric" },
{ ab: Abilities.PIXILATE, ab_name: "Pixilate", ty: PokemonType.FAIRY, tyName: "fairy" },
{ ab: Abilities.REFRIGERATE, ab_name: "Refrigerate", ty: PokemonType.ICE, tyName: "ice" },
{ ab: Abilities.AERILATE, ab_name: "Aerilate", ty: PokemonType.FLYING, tyName: "flying" },
])("Abilities - $ab_name", ({ ab, ty, tyName }) => {
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")
.startingLevel(100)
.starterSpecies(Species.MAGIKARP)
.ability(ab)
.moveset([Moves.TACKLE, Moves.REVELATION_DANCE, Moves.FURY_SWIPES])
.enemySpecies(Species.DUSCLOPS)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH)
.enemyLevel(100);
});
it(`should change Normal-type attacks to ${tyName} type and boost their power`, async () => {
await game.classicMode.startBattle();
const playerPokemon = game.scene.getPlayerPokemon()!;
const typeSpy = vi.spyOn(playerPokemon, "getMoveType");
const enemyPokemon = game.scene.getEnemyPokemon()!;
const enemySpy = vi.spyOn(enemyPokemon, "getMoveEffectiveness");
const powerSpy = vi.spyOn(allMoves[Moves.TACKLE], "calculateBattlePower");
game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to("BerryPhase", false);
expect(typeSpy).toHaveLastReturnedWith(ty);
expect(enemySpy).toHaveReturnedWith(1);
expect(powerSpy).toHaveReturnedWith(48);
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
});
// Galvanize specifically would like to check for volt absorb's activation
if (ab === Abilities.GALVANIZE) {
it("should cause Normal-type attacks to activate Volt Absorb", async () => {
game.override.enemyAbility(Abilities.VOLT_ABSORB);
await game.classicMode.startBattle();
const playerPokemon = game.scene.getPlayerPokemon()!;
const tySpy = vi.spyOn(playerPokemon, "getMoveType");
const enemyPokemon = game.scene.getEnemyPokemon()!;
const enemyEffectivenessSpy = vi.spyOn(enemyPokemon, "getMoveEffectiveness");
enemyPokemon.hp = Math.floor(enemyPokemon.getMaxHp() * 0.8);
game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to("BerryPhase", false);
expect(tySpy).toHaveLastReturnedWith(PokemonType.ELECTRIC);
expect(enemyEffectivenessSpy).toHaveReturnedWith(0);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
});
}
it.each([
{ moveName: "Revelation Dance", move: Moves.REVELATION_DANCE, expected_ty: PokemonType.WATER },
{ moveName: "Judgement", move: Moves.JUDGMENT, expected_ty: PokemonType.NORMAL },
{ moveName: "Terrain Pulse", move: Moves.TERRAIN_PULSE, expected_ty: PokemonType.NORMAL },
{ moveName: "Weather Ball", move: Moves.WEATHER_BALL, expected_ty: PokemonType.NORMAL },
{ moveName: "Multi Attack", move: Moves.MULTI_ATTACK, expected_ty: PokemonType.NORMAL },
{ moveName: "Techno Blast", move: Moves.TECHNO_BLAST, expected_ty: PokemonType.NORMAL },
])("should not change the type of $moveName", async ({ move, expected_ty: expectedTy }) => {
game.override
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.BALL_FETCH)
.moveset([move])
.starterSpecies(Species.MAGIKARP);
await game.classicMode.startBattle([Species.MAGIKARP]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const tySpy = vi.spyOn(playerPokemon, "getMoveType");
game.move.select(move);
await game.phaseInterceptor.to("BerryPhase", false);
expect(tySpy).toHaveLastReturnedWith(expectedTy);
});
it("should affect all hits of a Normal-type multi-hit move", async () => {
await game.classicMode.startBattle();
const playerPokemon = game.scene.getPlayerPokemon()!;
const tySpy = vi.spyOn(playerPokemon, "getMoveType");
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.FURY_SWIPES);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.move.forceHit();
await game.phaseInterceptor.to("MoveEffectPhase");
expect(playerPokemon.turnData.hitCount).toBeGreaterThan(1);
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
while (playerPokemon.turnData.hitsLeft > 0) {
const enemyStartingHp = enemyPokemon.hp;
await game.phaseInterceptor.to("MoveEffectPhase");
expect(tySpy).toHaveLastReturnedWith(ty);
expect(enemyPokemon.hp).toBeLessThan(enemyStartingHp);
}
});
it("should not be affected by silk scarf after changing the move's type", async () => {
game.override.startingHeldItems([{ name: "ATTACK_TYPE_BOOSTER", count: 1, type: PokemonType.NORMAL }]);
await game.classicMode.startBattle();
const testMoveInstance = allMoves[Moves.TACKLE];
// get the power boost from the ability so we can compare it to the item
// @ts-expect-error power multiplier is private
const boost = allAbilities[ab]?.getAttrs(MoveTypeChangeAbAttr)[0]?.powerMultiplier;
expect(boost, "power boost should be defined").toBeDefined();
const powerSpy = vi.spyOn(testMoveInstance, "calculateBattlePower");
const typeSpy = vi.spyOn(game.scene.getPlayerPokemon()!, "getMoveType");
game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to("BerryPhase", false);
expect(typeSpy, "type was not changed").toHaveLastReturnedWith(ty);
expect(powerSpy).toHaveLastReturnedWith(toDmgValue(testMoveInstance.power * boost));
});
it("should be affected by the type boosting item after changing the move's type", async () => {
game.override.startingHeldItems([{ name: "ATTACK_TYPE_BOOSTER", count: 1, type: ty }]);
await game.classicMode.startBattle();
// get the power boost from the ability so we can compare it to the item
// @ts-expect-error power multiplier is private
const boost = allAbilities[ab]?.getAttrs(MoveTypeChangeAbAttr)[0]?.powerMultiplier;
expect(boost, "power boost should be defined").toBeDefined();
const tackle = allMoves[Moves.TACKLE];
const spy = vi.spyOn(tackle, "calculateBattlePower");
game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to("BerryPhase", false);
expect(spy).toHaveLastReturnedWith(toDmgValue(tackle.power * boost * (1 + TYPE_BOOST_ITEM_BOOST_PERCENT / 100)));
});
});

View File

@ -0,0 +1,92 @@
import { TYPE_BOOST_ITEM_BOOST_PERCENT } from "#app/constants";
import { allMoves } from "#app/data/moves/move";
import { toDmgValue } from "#app/utils/common";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { PokemonType } from "#enums/pokemon-type";
import { Species } from "#enums/species";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Abilities - Normalize", () => {
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
.moveset([Moves.TACKLE])
.ability(Abilities.NORMALIZE)
.battleStyle("single")
.disableCrits()
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH);
});
it("should boost the power of normal type moves by 20%", async () => {
await game.classicMode.startBattle([Species.MAGIKARP]);
const powerSpy = vi.spyOn(allMoves[Moves.TACKLE], "calculateBattlePower");
game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to("BerryPhase");
expect(powerSpy).toHaveLastReturnedWith(toDmgValue(allMoves[Moves.TACKLE].power * 1.2));
});
it("should not apply the old type boost item after changing a move's type", async () => {
game.override.startingHeldItems([{ name: "ATTACK_TYPE_BOOSTER", count: 1, type: PokemonType.GRASS }]);
game.override.moveset([Moves.LEAFAGE]);
const powerSpy = vi.spyOn(allMoves[Moves.LEAFAGE], "calculateBattlePower");
await game.classicMode.startBattle([Species.MAGIKARP]);
game.move.select(Moves.LEAFAGE);
await game.phaseInterceptor.to("BerryPhase");
// It should return with 1.2 (that is, only the power boost from the ability)
expect(powerSpy).toHaveLastReturnedWith(toDmgValue(allMoves[Moves.LEAFAGE].power * 1.2));
});
it("should apply silk scarf's power boost after changing a move's type", async () => {
game.override.startingHeldItems([{ name: "ATTACK_TYPE_BOOSTER", count: 1, type: PokemonType.NORMAL }]);
game.override.moveset([Moves.LEAFAGE]);
const powerSpy = vi.spyOn(allMoves[Moves.LEAFAGE], "calculateBattlePower");
await game.classicMode.startBattle([Species.MAGIKARP]);
game.move.select(Moves.LEAFAGE);
await game.phaseInterceptor.to("BerryPhase");
// 1.2 from normalize boost, second 1.2 from
expect(powerSpy).toHaveLastReturnedWith(
toDmgValue(allMoves[Moves.LEAFAGE].power * 1.2 * (1 + TYPE_BOOST_ITEM_BOOST_PERCENT / 100)),
);
});
it.each([
{ moveName: "Revelation Dance", move: Moves.REVELATION_DANCE },
{ moveName: "Judgement", move: Moves.JUDGMENT, expected_ty: PokemonType.NORMAL },
{ moveName: "Terrain Pulse", move: Moves.TERRAIN_PULSE },
{ moveName: "Weather Ball", move: Moves.WEATHER_BALL },
{ moveName: "Multi Attack", move: Moves.MULTI_ATTACK },
{ moveName: "Techno Blast", move: Moves.TECHNO_BLAST },
{ moveName: "Hidden Power", move: Moves.HIDDEN_POWER },
])("should not boost the power of $moveName", async ({ move }) => {
game.override.moveset([move]);
await game.classicMode.startBattle([Species.MAGIKARP]);
const powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower");
game.move.select(move);
await game.phaseInterceptor.to("BerryPhase");
expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power);
});
});

View File

@ -75,7 +75,7 @@ describe("Moves - Tera Blast", () => {
await game.phaseInterceptor.to("MoveEffectPhase");
expect(moveToCheck.calculateBattlePower).toHaveReturnedWith(100);
}, 20000);
});
it("is super effective against terastallized targets if user is Stellar tera type", async () => {
await game.classicMode.startBattle();
@ -189,5 +189,33 @@ describe("Moves - Tera Blast", () => {
expect(playerPokemon.getStatStage(Stat.SPATK)).toBe(-1);
expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-1);
}, 20000);
});
it.each([
{ ab: "galvanize", ty: "electric", ab_id: Abilities.GALVANIZE, ty_id: PokemonType.ELECTRIC },
{ ab: "refrigerate", ty: "ice", ab_id: Abilities.REFRIGERATE, ty_id: PokemonType.ICE },
{ ab: "pixilate", ty: "fairy", ab_id: Abilities.PIXILATE, ty_id: PokemonType.FAIRY },
{ ab: "aerilate", ty: "flying", ab_id: Abilities.AERILATE, ty_id: PokemonType.FLYING },
])("should be $ty type if the user has $ab", async ({ ab_id, ty_id }) => {
game.override.ability(ab_id).moveset([Moves.TERA_BLAST]).enemyAbility(Abilities.BALL_FETCH);
await game.classicMode.startBattle([Species.MAGIKARP]);
const playerPokemon = game.scene.getPlayerPokemon()!;
expect(playerPokemon.getMoveType(allMoves[Moves.TERA_BLAST])).toBe(ty_id);
});
it("should not be affected by normalize when the user is terastallized with tera normal", async () => {
game.override.moveset([Moves.TERA_BLAST]).ability(Abilities.NORMALIZE);
await game.classicMode.startBattle([Species.MAGIKARP]);
const playerPokemon = game.scene.getPlayerPokemon()!;
// override the tera state for the pokemon
playerPokemon.isTerastallized = true;
playerPokemon.teraType = PokemonType.NORMAL;
const move = allMoves[Moves.TERA_BLAST];
const powerSpy = vi.spyOn(move, "calculateBattlePower");
game.move.select(Moves.TERA_BLAST);
await game.phaseInterceptor.to("BerryPhase");
expect(powerSpy).toHaveLastReturnedWith(move.power);
});
});