[Move] Implement Powder (with edge case) (#3662)

* Powder basic implementation

* Add Powder integration tests

* Fix thaw test

* Use new test utils and type check function

* More edge case tests

* Make Powder (P)

* Add locale keys

* Add placeholder common anim

* Use CommonAnimPhase instead of async animation

* Add comments with new English text

* Make Powder `edgeCase` instead

* ESLint

* Fix imports

* Add `moveName` key arg for other languages

* ESLint

* Update locales

* Fix pages issues

* Update Powder explosion animation

* Update common-powder.json

* Update src/test/moves/powder.test.ts

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

* Remove lingering TIMEOUTs

* More test cleanup

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
This commit is contained in:
innerthunder 2024-12-01 14:27:55 -08:00 committed by GitHub
parent 1607a694c3
commit e930536efe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 2758 additions and 1 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -90,6 +90,7 @@ export enum CommonAnim {
RAGING_BULL_FIRE,
RAGING_BULL_WATER,
SALT_CURE,
POWDER,
SUNNY = 2100,
RAIN,
SANDSTORM,

View File

@ -856,6 +856,57 @@ export class SeedTag extends BattlerTag {
}
}
/**
* BattlerTag representing the effects of {@link https://bulbapedia.bulbagarden.net/wiki/Powder_(move) | Powder}.
* When the afflicted Pokemon uses a Fire-type move, the move is cancelled, and the
* Pokemon takes damage equal to 1/4 of it's maximum HP (rounded down).
*/
export class PowderTag extends BattlerTag {
constructor() {
super(BattlerTagType.POWDER, [ BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END ], 1);
}
onAdd(pokemon: Pokemon): void {
super.onAdd(pokemon);
// "{Pokemon} is covered in powder!"
pokemon.scene.queueMessage(i18next.t("battlerTags:powderOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }));
}
/**
* Applies Powder's effects before the tag owner uses a Fire-type move.
* Also causes the tag to expire at the end of turn.
* @param pokemon {@linkcode Pokemon} the owner of this tag
* @param lapseType {@linkcode BattlerTagLapseType} the type of lapse functionality to carry out
* @returns `true` if the tag should not expire after this lapse; `false` otherwise.
*/
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
if (lapseType === BattlerTagLapseType.PRE_MOVE) {
const movePhase = pokemon.scene.getCurrentPhase();
if (movePhase instanceof MovePhase) {
const move = movePhase.move.getMove();
if (pokemon.getMoveType(move) === Type.FIRE) {
movePhase.cancel();
pokemon.scene.unshiftPhase(new CommonAnimPhase(pokemon.scene, pokemon.getBattlerIndex(), pokemon.getBattlerIndex(), CommonAnim.POWDER));
const cancelDamage = new BooleanHolder(false);
applyAbAttrs(BlockNonDirectDamageAbAttr, pokemon, cancelDamage);
if (!cancelDamage.value) {
pokemon.damageAndUpdate(Math.floor(pokemon.getMaxHp() / 4), HitResult.OTHER);
}
// "When the flame touched the powder\non the Pokémon, it exploded!"
pokemon.scene.queueMessage(i18next.t("battlerTags:powderLapse", { moveName: move.name }));
}
}
return true;
} else {
return super.lapse(pokemon, lapseType);
}
}
}
export class NightmareTag extends BattlerTag {
constructor() {
super(BattlerTagType.NIGHTMARE, BattlerTagLapseType.TURN_END, 1, Moves.NIGHTMARE);
@ -2955,6 +3006,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source
return new InfatuatedTag(sourceMove, sourceId);
case BattlerTagType.SEEDED:
return new SeedTag(sourceId);
case BattlerTagType.POWDER:
return new PowderTag();
case BattlerTagType.NIGHTMARE:
return new NightmareTag();
case BattlerTagType.FRENZY:

View File

@ -9732,9 +9732,10 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK, Stat.SPD ], -1, false, { condition: (user, target, move) => target.status?.effect === StatusEffect.POISON || target.status?.effect === StatusEffect.TOXIC })
.target(MoveTarget.ALL_NEAR_ENEMIES),
new StatusMove(Moves.POWDER, Type.BUG, 100, 20, -1, 1, 6)
.attr(AddBattlerTagAttr, BattlerTagType.POWDER, false, true)
.ignoresSubstitute()
.powderMove()
.unimplemented(),
.edgeCase(), // does not cancel Fire-type moves generated by Dancer
new ChargingSelfStatusMove(Moves.GEOMANCY, Type.FAIRY, -1, 10, -1, 0, 6)
.chargeText(i18next.t("moveTriggers:isChargingPower", { pokemonName: "{USER}" }))
.attr(StatStageChangeAttr, [ Stat.SPATK, Stat.SPDEF, Stat.SPD ], 2, true)

View File

@ -93,4 +93,5 @@ export enum BattlerTagType {
GRUDGE = "GRUDGE",
PSYCHO_SHIFT = "PSYCHO_SHIFT",
ENDURE_TOKEN = "ENDURE_TOKEN",
POWDER = "POWDER",
}

View File

@ -0,0 +1,205 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import Phaser from "phaser";
import GameManager from "#test/utils/gameManager";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import { BerryPhase } from "#app/phases/berry-phase";
import { MoveResult } from "#app/field/pokemon";
import { Type } from "#enums/type";
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
import { StatusEffect } from "#enums/status-effect";
describe("Moves - Powder", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override.battleType("single");
game.override
.enemySpecies(Species.SNORLAX)
.enemyLevel(100)
.enemyMoveset(Moves.EMBER)
.enemyAbility(Abilities.INSOMNIA)
.startingLevel(100)
.moveset([ Moves.POWDER, Moves.SPLASH, Moves.FIERY_DANCE ]);
});
it(
"should cancel the target's Fire-type move and damage the target",
async () => {
await game.classicMode.startBattle([ Species.CHARIZARD ]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.POWDER);
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.hp).toBe(Math.ceil(3 * enemyPokemon.getMaxHp() / 4));
await game.toNextTurn();
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(enemyPokemon.hp).toBe(Math.ceil(3 * enemyPokemon.getMaxHp() / 4));
});
it(
"should have no effect against Grass-type Pokemon",
async () => {
game.override.enemySpecies(Species.AMOONGUSS);
await game.classicMode.startBattle([ Species.CHARIZARD ]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.POWDER);
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
});
it(
"should have no effect against Pokemon with Overcoat",
async () => {
game.override.enemyAbility(Abilities.OVERCOAT);
await game.classicMode.startBattle([ Species.CHARIZARD ]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.POWDER);
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
});
it(
"should not damage the target if the target has Magic Guard",
async () => {
game.override.enemyAbility(Abilities.MAGIC_GUARD);
await game.classicMode.startBattle([ Species.CHARIZARD ]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.POWDER);
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
});
it(
"should not prevent the target from thawing out with Flame Wheel",
async () => {
game.override
.enemyMoveset(Array(4).fill(Moves.FLAME_WHEEL))
.enemyStatusEffect(StatusEffect.FREEZE);
await game.classicMode.startBattle([ Species.CHARIZARD ]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.POWDER);
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.status?.effect).not.toBe(StatusEffect.FREEZE);
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.hp).toBe(Math.ceil(3 * enemyPokemon.getMaxHp() / 4));
}
);
it(
"should not allow a target with Protean to change to Fire type",
async () => {
game.override.enemyAbility(Abilities.PROTEAN);
await game.classicMode.startBattle([ Species.CHARIZARD ]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.POWDER);
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(enemyPokemon.summonData?.types).not.toBe(Type.FIRE);
});
// TODO: Implement this interaction to pass this test
it.skip(
"should cancel Fire-type moves generated by the target's Dancer ability",
async () => {
game.override
.enemySpecies(Species.BLASTOISE)
.enemyAbility(Abilities.DANCER);
await game.classicMode.startBattle([ Species.CHARIZARD ]);
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.FIERY_DANCE);
await game.phaseInterceptor.to(MoveEffectPhase);
const enemyStartingHp = enemyPokemon.hp;
await game.phaseInterceptor.to(BerryPhase, false);
// player should not take damage
expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp());
// enemy should have taken damage from player's Fiery Dance + 2 Powder procs
expect(enemyPokemon.hp).toBe(enemyStartingHp - 2 * Math.floor(enemyPokemon.getMaxHp() / 4));
});
it(
"should cancel Revelation Dance if it becomes a Fire-type move",
async () => {
game.override
.enemySpecies(Species.CHARIZARD)
.enemyMoveset(Array(4).fill(Moves.REVELATION_DANCE));
await game.classicMode.startBattle([ Species.CHARIZARD ]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.POWDER);
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.hp).toBe(Math.ceil(3 * enemyPokemon.getMaxHp() / 4));
});
it(
"should cancel Shell Trap and damage the target, even if the move would fail",
async () => {
game.override.enemyMoveset(Array(4).fill(Moves.SHELL_TRAP));
await game.classicMode.startBattle([ Species.CHARIZARD ]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
game.move.select(Moves.POWDER);
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.hp).toBe(Math.ceil(3 * enemyPokemon.getMaxHp() / 4));
});
});