Merge branch 'beta' into filter-test-gh-action-trigger

This commit is contained in:
Sirz Benjie 2025-05-01 22:01:08 -05:00 committed by GitHub
commit 2ab37bc5d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 486 additions and 185 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

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;
}
@ -6130,7 +6145,7 @@ export class RevivalBlessingAttr extends MoveEffectAttr {
const faintedPokemon = globalScene.getEnemyParty().filter((p) => p.isFainted() && !p.isBoss());
const pokemon = faintedPokemon[user.randSeedInt(faintedPokemon.length)];
const slotIndex = globalScene.getEnemyParty().findIndex((p) => pokemon.id === p.id);
pokemon.resetStatus();
pokemon.resetStatus(true, false, false, true);
pokemon.heal(Math.min(toDmgValue(0.5 * pokemon.getMaxHp()), pokemon.getMaxHp()));
globalScene.queueMessage(i18next.t("moveTriggers:revivalBlessing", { pokemonName: getPokemonNameWithAffix(pokemon) }), 0, true);
const allyPokemon = user.getAlly();

View File

@ -106,7 +106,7 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui
* If an event with more than 1 valid event encounter species is active, you have 20% chance to get one of those
* If the rolled species has no HA, and there are valid event encounters, you will get one of those
* If the rolled species has no HA and there are no valid event encounters, you will get Shiny Magikarp
* Mons rolled from the event encounter pool get 2 extra shiny rolls
* Mons rolled from the event encounter pool get 3 extra shiny rolls
*/
if (
r === 0 ||
@ -120,12 +120,13 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = MysteryEncounterBui
(validEventEncounters.length > 0 && (r <= EVENT_THRESHOLD ||
(isNullOrUndefined(species.abilityHidden) || species.abilityHidden === Abilities.NONE)))
) {
// If you roll 20%, give event encounter with 2 extra shiny rolls and its HA, if it has one
// If you roll 20%, give event encounter with 3 extra shiny rolls and its HA, if it has one
const enc = randSeedItem(validEventEncounters);
species = getPokemonSpecies(enc.species);
pokemon = new PlayerPokemon(species, 5, species.abilityHidden === Abilities.NONE ? undefined : 2, enc.formIndex);
pokemon.trySetShinySeed();
pokemon.trySetShinySeed();
pokemon.trySetShinySeed();
} else {
pokemon = new PlayerPokemon(species, 5, 2, species.formIndex);
}

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,
@ -5602,13 +5608,44 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* @param revive Whether revive should be cured; defaults to true.
* @param confusion Whether resetStatus should include confusion or not; defaults to false.
* @param reloadAssets Whether to reload the assets or not; defaults to false.
* @param asPhase Whether to reset the status in a phase or immediately
*/
resetStatus(revive = true, confusion = false, reloadAssets = false): void {
resetStatus(revive = true, confusion = false, reloadAssets = false, asPhase = true): void {
const lastStatus = this.status?.effect;
if (!revive && lastStatus === StatusEffect.FAINT) {
return;
}
globalScene.unshiftPhase(new ResetStatusPhase(this, confusion, reloadAssets));
if (asPhase) {
globalScene.unshiftPhase(new ResetStatusPhase(this, confusion, reloadAssets));
} else {
this.clearStatus(confusion, reloadAssets);
}
}
/**
* Performs the action of clearing a Pokemon's status
*
* This is a helper to {@linkcode resetStatus}, which should be called directly instead of this method
*/
public clearStatus(confusion: boolean, reloadAssets: boolean) {
const lastStatus = this.status?.effect;
this.status = null;
if (lastStatus === StatusEffect.SLEEP) {
this.setFrameRate(10);
if (this.getTag(BattlerTagType.NIGHTMARE)) {
this.lapseTag(BattlerTagType.NIGHTMARE);
}
}
if (confusion) {
if (this.getTag(BattlerTagType.CONFUSED)) {
this.lapseTag(BattlerTagType.CONFUSED);
}
}
if (reloadAssets) {
this.loadAssets(false).then(() => this.playAnim());
}
this.updateInfo(true);
}
/**

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
);
}
@ -1952,7 +1953,7 @@ export class PokemonInstantReviveModifier extends PokemonHeldItemModifier {
);
// Remove the Pokemon's FAINT status
pokemon.resetStatus(true, false, true);
pokemon.resetStatus(true, false, true, false);
// Reapply Commander on the Pokemon's side of the field, if applicable
const field = pokemon.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField();
@ -2160,7 +2161,7 @@ export class PokemonHpRestoreModifier extends ConsumablePokemonModifier {
restorePoints = Math.floor(restorePoints * multiplier);
}
if (this.fainted || this.healStatus) {
pokemon.resetStatus(true, true);
pokemon.resetStatus(true, true, false, false);
}
pokemon.hp = Math.min(
pokemon.hp +
@ -2180,7 +2181,7 @@ export class PokemonStatusHealModifier extends ConsumablePokemonModifier {
* @returns always `true`
*/
override apply(playerPokemon: PlayerPokemon): boolean {
playerPokemon.resetStatus(true, true);
playerPokemon.resetStatus(true, true, false, false);
return true;
}
}

View File

@ -21,7 +21,7 @@ export class PartyHealPhase extends BattlePhase {
globalScene.ui.fadeOut(1000).then(() => {
for (const pokemon of globalScene.getPlayerParty()) {
pokemon.hp = pokemon.getMaxHp();
pokemon.resetStatus();
pokemon.resetStatus(true, false, false, true);
for (const move of pokemon.moveset) {
move.ppUsed = 0;
}

View File

@ -1,7 +1,5 @@
import type Pokemon from "#app/field/pokemon";
import { BattlePhase } from "#app/phases/battle-phase";
import { BattlerTagType } from "#enums/battler-tag-type";
import { StatusEffect } from "#enums/status-effect";
/**
* Phase which handles resetting a Pokemon's status to none
@ -22,23 +20,7 @@ export class ResetStatusPhase extends BattlePhase {
}
public override start() {
const lastStatus = this.pokemon.status?.effect;
this.pokemon.status = null;
if (lastStatus === StatusEffect.SLEEP) {
this.pokemon.setFrameRate(10);
if (this.pokemon.getTag(BattlerTagType.NIGHTMARE)) {
this.pokemon.lapseTag(BattlerTagType.NIGHTMARE);
}
}
if (this.affectConfusion) {
if (this.pokemon.getTag(BattlerTagType.CONFUSED)) {
this.pokemon.lapseTag(BattlerTagType.CONFUSED);
}
}
if (this.reloadAssets) {
this.pokemon.loadAssets(false).then(() => this.pokemon.playAnim());
}
this.pokemon.updateInfo(true);
this.pokemon.clearStatus(this.affectConfusion, this.reloadAssets);
this.end();
}
}

View File

@ -32,7 +32,7 @@ export class RevivalBlessingPhase extends BattlePhase {
}
pokemon.resetTurnData();
pokemon.resetStatus();
pokemon.resetStatus(true, false, false, false);
pokemon.heal(Math.min(toDmgValue(0.5 * pokemon.getMaxHp()), pokemon.getMaxHp()));
globalScene.queueMessage(
i18next.t("moveTriggers:revivalBlessing", {

View File

@ -310,6 +310,48 @@ const timedEvents: TimedEvent[] = [
},
],
},
{
name: "Shining Spring",
eventType: EventType.SHINY,
startDate: new Date(Date.UTC(2025, 4, 2)),
endDate: new Date(Date.UTC(2025, 4, 12)),
bannerKey: "spr25event",
scale: 0.21,
availableLangs: ["en", "de", "it", "fr", "ja", "ko", "es-ES", "es-MX", "pt-BR", "zh-CN"],
shinyMultiplier: 2,
upgradeUnlockedVouchers: true,
eventEncounters: [
{ species: Species.HOPPIP },
{ species: Species.CELEBI },
{ species: Species.VOLBEAT },
{ species: Species.ILLUMISE },
{ species: Species.SPOINK },
{ species: Species.LILEEP },
{ species: Species.SHINX },
{ species: Species.PACHIRISU },
{ species: Species.CHERUBI },
{ species: Species.MUNCHLAX },
{ species: Species.TEPIG },
{ species: Species.PANSAGE },
{ species: Species.PANSEAR },
{ species: Species.PANPOUR },
{ species: Species.DARUMAKA },
{ species: Species.ARCHEN },
{ species: Species.DEERLING, formIndex: 0 }, // Spring Deerling
{ species: Species.CLAUNCHER },
{ species: Species.WISHIWASHI },
{ species: Species.MUDBRAY },
{ species: Species.DRAMPA },
{ species: Species.JANGMO_O },
{ species: Species.APPLIN },
],
classicWaveRewards: [
{ wave: 8, type: "SHINY_CHARM" },
{ wave: 8, type: "ABILITY_CHARM" },
{ wave: 8, type: "CATCHING_CHARM" },
{ wave: 25, type: "SHINY_CHARM" },
],
}
];
export class TimedEventManager {

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);
});
});