[Ability] Partially implement Gulp Missile (#3327)

* implement gulp missile

* add tests

* change fly to dive

* show ability when reverting to normal form

* update ai score, tests

* update score condition

* adjust conditions, damage

* add underwater test

* update damage in test

* partial commit
This commit is contained in:
Adrian T. 2024-08-07 04:18:47 +08:00 committed by GitHub
parent 9eadce80c6
commit 9875fcc59d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 404 additions and 5 deletions

View File

@ -6,7 +6,7 @@ import { BattleStat, getBattleStatName } from "./battle-stat";
import { MovePhase, PokemonHealPhase, ShowAbilityPhase, StatChangePhase } from "../phases";
import { getPokemonNameWithAffix } from "../messages";
import { Weather, WeatherType } from "./weather";
import { BattlerTag, GroundedTag } from "./battler-tags";
import { BattlerTag, GroundedTag, GulpMissileTag } from "./battler-tags";
import { StatusEffect, getNonVolatileStatusEffects, getStatusEffectDescriptor, getStatusEffectHealText } from "./status-effect";
import { Gender } from "./gender";
import Move, { AttackMove, MoveCategory, MoveFlags, MoveTarget, FlinchAttr, OneHitKOAttr, HitHealAttr, allMoves, StatusMove, SelfStatusMove, VariablePowerAttr, applyMoveAttrs, IncrementMovePriorityAttr, VariableMoveTypeAttr, RandomMovesetMoveAttr, RandomMoveAttr, NaturePowerAttr, CopyMoveAttr, MoveAttr, MultiHitAttr, ChargeAttr, SacrificialAttr, SacrificialAttrOnHit } from "./move";
@ -496,6 +496,49 @@ export class PostDefendAbAttr extends AbAttr {
}
}
/**
* Applies the effects of Gulp Missile when the user is hit by an attack.
* @extends PostDefendAbAttr
*/
export class PostDefendGulpMissileAbAttr extends PostDefendAbAttr {
constructor() {
super(true);
}
/**
* Damages the attacker and triggers the secondary effect based on the form or the BattlerTagType.
* @param {Pokemon} pokemon - The defending Pokemon.
* @param passive - n/a
* @param {Pokemon} attacker - The attacking Pokemon.
* @param {Move} move - The move being used.
* @param {HitResult} hitResult - n/a
* @param {any[]} args - n/a
* @returns Whether the effects of the ability are applied.
*/
applyPostDefend(pokemon: Pokemon, passive: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean | Promise<boolean> {
const battlerTag = pokemon.getTag(GulpMissileTag);
if (!battlerTag || move.category === MoveCategory.STATUS) {
return false;
}
const cancelled = new Utils.BooleanHolder(false);
applyAbAttrs(BlockNonDirectDamageAbAttr, attacker, cancelled);
if (!cancelled.value) {
attacker.damageAndUpdate(Math.max(1, Math.floor(attacker.getMaxHp() / 4)), HitResult.OTHER);
}
if (battlerTag.tagType === BattlerTagType.GULP_MISSILE_ARROKUDA) {
pokemon.scene.unshiftPhase(new StatChangePhase(pokemon.scene, attacker.getBattlerIndex(), false, [ BattleStat.DEF ], -1));
} else {
attacker.trySetStatus(StatusEffect.PARALYSIS, true, pokemon);
}
pokemon.removeTag(battlerTag.tagType);
return true;
}
}
export class PostDefendDisguiseAbAttr extends PostDefendAbAttr {
applyPostDefend(pokemon: Pokemon, passive: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean {
@ -5087,7 +5130,11 @@ export function initAbilities() {
.attr(UnsuppressableAbilityAbAttr)
.attr(NoTransformAbilityAbAttr)
.attr(NoFusionAbilityAbAttr)
.unimplemented(),
.attr(UncopiableAbilityAbAttr)
.attr(UnswappableAbilityAbAttr)
.attr(PostDefendGulpMissileAbAttr)
// Does not transform when Surf/Dive misses/is protected
.partial(),
new Ability(Abilities.STALWART, 8)
.attr(BlockRedirectAbAttr),
new Ability(Abilities.STEAM_ENGINE, 8)

View File

@ -1647,6 +1647,39 @@ export class StockpilingTag extends BattlerTag {
}
}
/**
* Battler tag for Gulp Missile used by Cramorant.
* @extends BattlerTag
*/
export class GulpMissileTag extends BattlerTag {
constructor(tagType: BattlerTagType, sourceMove: Moves) {
super(tagType, BattlerTagLapseType.CUSTOM, 0, sourceMove);
}
/**
* Gulp Missile's initial form changes are triggered by using Surf and Dive.
* @param {Pokemon} pokemon The Pokemon with Gulp Missile ability.
* @returns Whether the BattlerTag can be added.
*/
canAdd(pokemon: Pokemon): boolean {
const isSurfOrDive = [ Moves.SURF, Moves.DIVE ].includes(this.sourceMove);
const isNormalForm = pokemon.formIndex === 0 && !pokemon.getTag(BattlerTagType.GULP_MISSILE_ARROKUDA) && !pokemon.getTag(BattlerTagType.GULP_MISSILE_PIKACHU);
const isCramorant = pokemon.species.speciesId === Species.CRAMORANT;
return isSurfOrDive && isNormalForm && isCramorant;
}
onAdd(pokemon: Pokemon): void {
super.onAdd(pokemon);
pokemon.scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeManualTrigger);
}
onRemove(pokemon: Pokemon): void {
super.onRemove(pokemon);
pokemon.scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeManualTrigger);
}
}
export function getBattlerTag(tagType: BattlerTagType, turnCount: number, sourceMove: Moves, sourceId: number): BattlerTag {
switch (tagType) {
case BattlerTagType.RECHARGING:
@ -1770,6 +1803,9 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source
return new StockpilingTag(sourceMove);
case BattlerTagType.OCTOLOCK:
return new OctolockTag(sourceId);
case BattlerTagType.GULP_MISSILE_ARROKUDA:
case BattlerTagType.GULP_MISSILE_PIKACHU:
return new GulpMissileTag(tagType, sourceMove);
case BattlerTagType.NONE:
default:
return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId);

View File

@ -1,7 +1,7 @@
import { ChargeAnim, MoveChargeAnim, initMoveAnim, loadMoveAnimAssets } from "./battle-anims";
import { BattleEndPhase, MoveEndPhase, MovePhase, NewBattlePhase, PartyStatusCurePhase, PokemonHealPhase, StatChangePhase, SwitchSummonPhase } from "../phases";
import { BattleStat, getBattleStatName } from "./battle-stat";
import { EncoreTag, HelpingHandTag, SemiInvulnerableTag, StockpilingTag, TypeBoostTag } from "./battler-tags";
import { EncoreTag, GulpMissileTag, HelpingHandTag, SemiInvulnerableTag, StockpilingTag, TypeBoostTag } from "./battler-tags";
import { getPokemonNameWithAffix } from "../messages";
import Pokemon, { AttackMoveResult, EnemyPokemon, HitResult, MoveResult, PlayerPokemon, PokemonMove, TurnMove } from "../field/pokemon";
import { StatusEffect, getStatusEffectHealText, isNonVolatileStatusEffect, getNonVolatileStatusEffects} from "./status-effect";
@ -4300,6 +4300,46 @@ export class AddBattlerTagAttr extends MoveEffectAttr {
}
}
/**
* Adds the appropriate battler tag for Gulp Missile when Surf or Dive is used.
* @extends MoveEffectAttr
*/
export class GulpMissileTagAttr extends MoveEffectAttr {
constructor() {
super(true, MoveEffectTrigger.POST_APPLY);
}
/**
* Adds BattlerTagType from GulpMissileTag based on the Pokemon's HP ratio.
* @param {Pokemon} user The Pokemon using the move.
* @param {Pokemon} target The Pokemon being targeted by the move.
* @param {Move} move The move being used.
* @param {any[]} args Additional arguments, if any.
* @returns Whether the BattlerTag is applied.
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean | Promise<boolean> {
if (!super.apply(user, target, move, args)) {
return false;
}
if (user.hasAbility(Abilities.GULP_MISSILE) && user.species.speciesId === Species.CRAMORANT) {
if (user.getHpRatio() >= .5) {
user.addTag(BattlerTagType.GULP_MISSILE_ARROKUDA, 0, move.id);
} else {
user.addTag(BattlerTagType.GULP_MISSILE_PIKACHU, 0, move.id);
}
return true;
}
return false;
}
getUserBenefitScore(user: Pokemon, target: Pokemon, move: Move): integer {
const isCramorant = user.hasAbility(Abilities.GULP_MISSILE) && user.species.speciesId === Species.CRAMORANT;
return isCramorant && !user.getTag(GulpMissileTag) ? 10 : 0;
}
}
export class CurseAttr extends MoveEffectAttr {
apply(user: Pokemon, target: Pokemon, move:Move, args: any[]): boolean {
@ -6157,7 +6197,8 @@ export function initMoves() {
new AttackMove(Moves.HYDRO_PUMP, Type.WATER, MoveCategory.SPECIAL, 110, 80, 5, -1, 0, 1),
new AttackMove(Moves.SURF, Type.WATER, MoveCategory.SPECIAL, 90, 100, 15, -1, 0, 1)
.target(MoveTarget.ALL_NEAR_OTHERS)
.attr(HitsTagAttr, BattlerTagType.UNDERWATER, true),
.attr(HitsTagAttr, BattlerTagType.UNDERWATER, true)
.attr(GulpMissileTagAttr),
new AttackMove(Moves.ICE_BEAM, Type.ICE, MoveCategory.SPECIAL, 90, 100, 10, 10, 0, 1)
.attr(StatusEffectAttr, StatusEffect.FREEZE),
new AttackMove(Moves.BLIZZARD, Type.ICE, MoveCategory.SPECIAL, 110, 70, 5, 10, 0, 1)
@ -6822,6 +6863,7 @@ export function initMoves() {
.partial(),
new AttackMove(Moves.DIVE, Type.WATER, MoveCategory.PHYSICAL, 80, 100, 10, -1, 0, 3)
.attr(ChargeAttr, ChargeAnim.DIVE_CHARGING, i18next.t("moveTriggers:hidUnderwater", {pokemonName: "{USER}"}), BattlerTagType.UNDERWATER)
.attr(GulpMissileTagAttr)
.ignoresVirtual(),
new AttackMove(Moves.ARM_THRUST, Type.FIGHTING, MoveCategory.PHYSICAL, 15, 100, 20, -1, 0, 3)
.attr(MultiHitAttr),

View File

@ -828,6 +828,12 @@ export const pokemonFormChanges: PokemonFormChanges = {
[Species.EISCUE]: [
new SpeciesFormChange(Species.EISCUE, "", "no-ice", new SpeciesFormChangeManualTrigger(), true),
new SpeciesFormChange(Species.EISCUE, "no-ice", "", new SpeciesFormChangeManualTrigger(), true),
],
[Species.CRAMORANT]: [
new SpeciesFormChange(Species.CRAMORANT, "", "gulping", new SpeciesFormChangeManualTrigger, true, new SpeciesFormChangeCondition(p => p.getHpRatio() >= .5)),
new SpeciesFormChange(Species.CRAMORANT, "", "gorging", new SpeciesFormChangeManualTrigger, true, new SpeciesFormChangeCondition(p => p.getHpRatio() < .5)),
new SpeciesFormChange(Species.CRAMORANT, "gulping", "", new SpeciesFormChangeManualTrigger, true),
new SpeciesFormChange(Species.CRAMORANT, "gorging", "", new SpeciesFormChangeManualTrigger, true),
]
};

View File

@ -63,5 +63,7 @@ export enum BattlerTagType {
ICE_FACE = "ICE_FACE",
STOCKPILING = "STOCKPILING",
RECEIVE_DOUBLE_DAMAGE = "RECEIVE_DOUBLE_DAMAGE",
ALWAYS_GET_HIT = "ALWAYS_GET_HIT"
ALWAYS_GET_HIT = "ALWAYS_GET_HIT",
GULP_MISSILE_ARROKUDA = "GULP_MISSILE_ARROKUDA",
GULP_MISSILE_PIKACHU = "GULP_MISSILE_PIKACHU"
}

View File

@ -0,0 +1,266 @@
import { BattlerTagType } from "#app/enums/battler-tag-type.js";
import {
MoveEndPhase,
TurnEndPhase,
TurnStartPhase,
} from "#app/phases";
import GameManager from "#app/test/utils/gameManager";
import { getMovePosition } from "#app/test/utils/gameManagerUtils";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { SPLASH_ONLY } from "../utils/testUtils";
import { BattleStat } from "#app/data/battle-stat.js";
import { StatusEffect } from "#app/enums/status-effect.js";
import { GulpMissileTag } from "#app/data/battler-tags.js";
import Pokemon from "#app/field/pokemon.js";
describe("Abilities - Gulp Missile", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
const NORMAL_FORM = 0;
const GULPING_FORM = 1;
const GORGING_FORM = 2;
/**
* Gets the effect damage of Gulp Missile
* See Gulp Missile {@link https://bulbapedia.bulbagarden.net/wiki/Gulp_Missile_(Ability)}
* @param {Pokemon} pokemon The pokemon taking the effect damage.
* @returns The effect damage of Gulp Missile
*/
const getEffectDamage = (pokemon: Pokemon): number => {
return Math.max(1, Math.floor(pokemon.getMaxHp() * 1/4));
};
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleType("single")
.moveset([Moves.SURF, Moves.DIVE, Moves.SPLASH])
.enemySpecies(Species.SNORLAX)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(SPLASH_ONLY)
.enemyLevel(5);
});
it("changes to Gulping Form if HP is over half when Surf or Dive is used", async () => {
await game.startBattle([Species.CRAMORANT]);
const cramorant = game.scene.getPlayerPokemon();
game.doAttack(getMovePosition(game.scene, 0, Moves.DIVE));
await game.toNextTurn();
game.doAttack(getMovePosition(game.scene, 0, Moves.DIVE));
await game.phaseInterceptor.to(MoveEndPhase);
expect(cramorant.getHpRatio()).toBeGreaterThanOrEqual(.5);
expect(cramorant.getTag(BattlerTagType.GULP_MISSILE_ARROKUDA)).toBeDefined();
expect(cramorant.formIndex).toBe(GULPING_FORM);
});
it("changes to Gorging Form if HP is under half when Surf or Dive is used", async () => {
await game.startBattle([Species.CRAMORANT]);
const cramorant = game.scene.getPlayerPokemon();
vi.spyOn(cramorant, "getHpRatio").mockReturnValue(.49);
expect(cramorant.getHpRatio()).toBe(.49);
game.doAttack(getMovePosition(game.scene, 0, Moves.SURF));
await game.phaseInterceptor.to(MoveEndPhase);
expect(cramorant.getTag(BattlerTagType.GULP_MISSILE_PIKACHU)).toBeDefined();
expect(cramorant.formIndex).toBe(GORGING_FORM);
});
it("deals ¼ of the attacker's maximum HP when hit by a damaging attack", async () => {
game.override.enemyMoveset(Array(4).fill(Moves.TACKLE));
await game.startBattle([Species.CRAMORANT]);
const enemy = game.scene.getEnemyPokemon();
vi.spyOn(enemy, "damageAndUpdate");
game.doAttack(getMovePosition(game.scene, 0, Moves.SURF));
await game.phaseInterceptor.to(TurnEndPhase);
expect(enemy.damageAndUpdate).toHaveReturnedWith(getEffectDamage(enemy));
});
it("does not have any effect when hit by non-damaging attack", async () => {
game.override.enemyMoveset(Array(4).fill(Moves.TAIL_WHIP));
await game.startBattle([Species.CRAMORANT]);
const cramorant = game.scene.getPlayerPokemon();
vi.spyOn(cramorant, "getHpRatio").mockReturnValue(.55);
game.doAttack(getMovePosition(game.scene, 0, Moves.SURF));
await game.phaseInterceptor.to(MoveEndPhase);
expect(cramorant.getTag(BattlerTagType.GULP_MISSILE_ARROKUDA)).toBeDefined();
expect(cramorant.formIndex).toBe(GULPING_FORM);
await game.phaseInterceptor.to(TurnEndPhase);
expect(cramorant.getTag(BattlerTagType.GULP_MISSILE_ARROKUDA)).toBeDefined();
expect(cramorant.formIndex).toBe(GULPING_FORM);
});
it("lowers the attacker's Defense by 1 stage when hit in Gulping form", async () => {
game.override.enemyMoveset(Array(4).fill(Moves.TACKLE));
await game.startBattle([Species.CRAMORANT]);
const cramorant = game.scene.getPlayerPokemon();
const enemy = game.scene.getEnemyPokemon();
vi.spyOn(enemy, "damageAndUpdate");
vi.spyOn(cramorant, "getHpRatio").mockReturnValue(.55);
game.doAttack(getMovePosition(game.scene, 0, Moves.SURF));
await game.phaseInterceptor.to(MoveEndPhase);
expect(cramorant.getTag(BattlerTagType.GULP_MISSILE_ARROKUDA)).toBeDefined();
expect(cramorant.formIndex).toBe(GULPING_FORM);
await game.phaseInterceptor.to(TurnEndPhase);
expect(enemy.damageAndUpdate).toHaveReturnedWith(getEffectDamage(enemy));
expect(enemy.summonData.battleStats[BattleStat.DEF]).toBe(-1);
expect(cramorant.getTag(BattlerTagType.GULP_MISSILE_ARROKUDA)).toBeUndefined();
expect(cramorant.formIndex).toBe(NORMAL_FORM);
});
it("paralyzes the enemy when hit in Gorging form", async () => {
game.override.enemyMoveset(Array(4).fill(Moves.TACKLE));
await game.startBattle([Species.CRAMORANT]);
const cramorant = game.scene.getPlayerPokemon();
const enemy = game.scene.getEnemyPokemon();
vi.spyOn(enemy, "damageAndUpdate");
vi.spyOn(cramorant, "getHpRatio").mockReturnValue(.45);
game.doAttack(getMovePosition(game.scene, 0, Moves.SURF));
await game.phaseInterceptor.to(MoveEndPhase);
expect(cramorant.getTag(BattlerTagType.GULP_MISSILE_PIKACHU)).toBeDefined();
expect(cramorant.formIndex).toBe(GORGING_FORM);
await game.phaseInterceptor.to(TurnEndPhase);
expect(enemy.damageAndUpdate).toHaveReturnedWith(getEffectDamage(enemy));
expect(enemy.status.effect).toBe(StatusEffect.PARALYSIS);
expect(cramorant.getTag(BattlerTagType.GULP_MISSILE_PIKACHU)).toBeUndefined();
expect(cramorant.formIndex).toBe(NORMAL_FORM);
});
it("does not activate the ability when underwater", async () => {
game.override
.enemyMoveset(Array(4).fill(Moves.SURF))
.enemySpecies(Species.REGIELEKI)
.enemyAbility(Abilities.BALL_FETCH)
.enemyLevel(5);
await game.startBattle([Species.CRAMORANT]);
const cramorant = game.scene.getPlayerPokemon();
game.doAttack(getMovePosition(game.scene, 0, Moves.DIVE));
await game.toNextTurn();
// Turn 2 underwater, enemy moves first
game.doAttack(getMovePosition(game.scene, 0, Moves.DIVE));
await game.phaseInterceptor.to(MoveEndPhase);
expect(cramorant.formIndex).toBe(NORMAL_FORM);
expect(cramorant.getTag(GulpMissileTag)).toBeUndefined();
// Turn 2 Cramorant comes out and changes form
await game.phaseInterceptor.to(TurnEndPhase);
expect(cramorant.formIndex).not.toBe(NORMAL_FORM);
expect(cramorant.getTag(GulpMissileTag)).toBeDefined();
});
it("prevents effect damage but inflicts secondary effect on attacker with Magic Guard", async () => {
game.override.enemyMoveset(Array(4).fill(Moves.TACKLE)).enemyAbility(Abilities.MAGIC_GUARD);
await game.startBattle([Species.CRAMORANT]);
const cramorant = game.scene.getPlayerPokemon();
const enemy = game.scene.getEnemyPokemon();
vi.spyOn(cramorant, "getHpRatio").mockReturnValue(.55);
game.doAttack(getMovePosition(game.scene, 0, Moves.SURF));
await game.phaseInterceptor.to(MoveEndPhase);
const enemyHpPreEffect = enemy.hp;
expect(cramorant.getTag(BattlerTagType.GULP_MISSILE_ARROKUDA)).toBeDefined();
expect(cramorant.formIndex).toBe(GULPING_FORM);
await game.phaseInterceptor.to(TurnEndPhase);
expect(enemy.hp).toBe(enemyHpPreEffect);
expect(enemy.summonData.battleStats[BattleStat.DEF]).toBe(-1);
expect(cramorant.getTag(BattlerTagType.GULP_MISSILE_ARROKUDA)).toBeUndefined();
expect(cramorant.formIndex).toBe(NORMAL_FORM);
});
it("cannot be suppressed", async () => {
game.override.enemyMoveset(Array(4).fill(Moves.GASTRO_ACID));
await game.startBattle([Species.CRAMORANT]);
const cramorant = game.scene.getPlayerPokemon();
vi.spyOn(cramorant, "getHpRatio").mockReturnValue(.55);
game.doAttack(getMovePosition(game.scene, 0, Moves.SURF));
await game.phaseInterceptor.to(MoveEndPhase);
expect(cramorant.getTag(BattlerTagType.GULP_MISSILE_ARROKUDA)).toBeDefined();
expect(cramorant.formIndex).toBe(GULPING_FORM);
await game.phaseInterceptor.to(TurnEndPhase);
expect(cramorant.hasAbility(Abilities.GULP_MISSILE)).toBe(true);
expect(cramorant.getTag(BattlerTagType.GULP_MISSILE_ARROKUDA)).toBeDefined();
expect(cramorant.formIndex).toBe(GULPING_FORM);
});
it("cannot be swapped with another ability", async () => {
game.override.enemyMoveset(Array(4).fill(Moves.SKILL_SWAP));
await game.startBattle([Species.CRAMORANT]);
const cramorant = game.scene.getPlayerPokemon();
vi.spyOn(cramorant, "getHpRatio").mockReturnValue(.55);
game.doAttack(getMovePosition(game.scene, 0, Moves.SURF));
await game.phaseInterceptor.to(MoveEndPhase);
expect(cramorant.getTag(BattlerTagType.GULP_MISSILE_ARROKUDA)).toBeDefined();
expect(cramorant.formIndex).toBe(GULPING_FORM);
await game.phaseInterceptor.to(TurnEndPhase);
expect(cramorant.hasAbility(Abilities.GULP_MISSILE)).toBe(true);
expect(cramorant.getTag(BattlerTagType.GULP_MISSILE_ARROKUDA)).toBeDefined();
expect(cramorant.formIndex).toBe(GULPING_FORM);
});
it("cannot be copied", async () => {
game.override.enemyAbility(Abilities.TRACE);
await game.startBattle([Species.CRAMORANT]);
game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH));
await game.phaseInterceptor.to(TurnStartPhase);
expect(game.scene.getEnemyPokemon().hasAbility(Abilities.GULP_MISSILE)).toBe(false);
});
});