Fixed info resetting and added backup leaveField check

is this bad? yes
does it work? maybe
am i going insane? 101%
This commit is contained in:
Bertie690 2025-05-18 18:41:00 -04:00
parent e743f79536
commit 2b0484a7cb
13 changed files with 252 additions and 180 deletions

View File

@ -1250,7 +1250,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr {
/**
* 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}
@ -1266,7 +1266,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr {
*/
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) &&
!noAbilityTypeOverrideMoves.has(move.id) &&
(!pokemon.isTerastallized ||
(move.id !== Moves.TERA_BLAST &&
(move.id !== Moves.TERA_STARSTORM || pokemon.getTeraType() !== PokemonType.STELLAR || !pokemon.hasSpecies(Species.TERAPAGOS))));
@ -6946,10 +6946,12 @@ export function initAbilities() {
.attr(PostDefendStatStageChangeAbAttr, (target, user, move) => move.category !== MoveCategory.STATUS, Stat.DEF, 1),
new Ability(Abilities.WIMP_OUT, 7)
.attr(PostDamageForceSwitchAbAttr)
.condition(getSheerForceHitDisableAbCondition()),
.condition(getSheerForceHitDisableAbCondition())
.bypassFaint(), // allows Wimp Out to activate with Reviver Seed
new Ability(Abilities.EMERGENCY_EXIT, 7)
.attr(PostDamageForceSwitchAbAttr)
.condition(getSheerForceHitDisableAbCondition()),
.condition(getSheerForceHitDisableAbCondition())
.bypassFaint(),
new Ability(Abilities.WATER_COMPACTION, 7)
.attr(PostDefendStatStageChangeAbAttr, (target, user, move) => user.getMoveType(move) === PokemonType.WATER && move.category !== MoveCategory.STATUS, Stat.DEF, 2),
new Ability(Abilities.MERCILESS, 7)

View File

@ -35,6 +35,12 @@ export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
protected canSwitchOut(switchOutTarget: Pokemon): boolean {
const isPlayer = switchOutTarget instanceof PlayerPokemon;
if (switchOutTarget.isFainted()) {
// Fainted Pokemon cannot be switched out by any means.
// This is already checked in `MoveEffectAttr.canApply`, but better safe than sorry
return false;
}
// If we aren't switching ourself out, ensure the target in question can actually be switched out by us
if (!this.selfSwitch && !this.performForceSwitchChecks(switchOutTarget)) {
return false;
@ -86,15 +92,10 @@ export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
/**
* Wrapper function to handle the actual "switching out" of Pokemon.
* @param switchOutTarget - The {@linkcode Pokemon} (player or enemy) to be switched switch out.
* @param switchOutTarget - The {@linkcode Pokemon} (player or enemy) to be switched out.
*/
protected doSwitch(switchOutTarget: Pokemon): void {
if (switchOutTarget instanceof PlayerPokemon) {
this.trySwitchPlayerPokemon(switchOutTarget);
return;
}
if (!(switchOutTarget instanceof EnemyPokemon)) {
if (!(switchOutTarget instanceof PlayerPokemon) && !(switchOutTarget instanceof EnemyPokemon)) {
console.warn(
"Switched out target (index %i) neither player nor enemy Pokemon!",
switchOutTarget.getFieldIndex(),
@ -102,17 +103,31 @@ export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
return;
}
if (globalScene.currentBattle.battleType !== BattleType.WILD) {
this.trySwitchTrainerPokemon(switchOutTarget);
return;
switch (true) {
case switchOutTarget instanceof PlayerPokemon:
this.trySwitchPlayerPokemon(switchOutTarget);
break;
case globalScene.currentBattle.battleType !== BattleType.WILD:
this.trySwitchTrainerPokemon(switchOutTarget);
break;
default:
this.tryFleeWildPokemon(switchOutTarget);
}
this.tryFleeWildPokemon(switchOutTarget);
// Hide the info container as soon as the switch out occurs.
// Effects are kept to ensure correct Shell Bell interactions.
// TODO: Should we hide the info container for wild fleeing?
// Currently keeping it same as prior logic for consistency
if (switchOutTarget instanceof EnemyPokemon && globalScene.currentBattle.battleType === BattleType.WILD) {
switchOutTarget.hideInfo();
}
}
// NB: `prependToPhase` is used here to ensure that the switch happens before the move ends
// and `arena.ignoreAbilities` is reset.
// This ensures ability ignore effects will persist for the duration of the switch (for hazards).
/*
NB: `prependToPhase` is used here to ensure that the switch happens before the move ends
and `arena.ignoreAbilities` is reset.
This ensures ability ignore effects will persist for the duration of the switch (for hazards, etc).
*/
private trySwitchPlayerPokemon(switchOutTarget: PlayerPokemon): void {
// If not forced to switch, add a SwitchPhase to allow picking the next switched in Pokemon.
@ -156,7 +171,6 @@ export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
private tryFleeWildPokemon(switchOutTarget: EnemyPokemon): void {
// flee wild pokemon, redirecting moves to an ally in doubles as applicable.
switchOutTarget.leaveField(false);
globalScene.queueMessage(
i18next.t("moveTriggers:fled", { pokemonName: getPokemonNameWithAffix(switchOutTarget) }),
null,
@ -169,7 +183,7 @@ export function ForceSwitch<TBase extends SubMoveOrAbAttr>(Base: TBase) {
globalScene.redirectPokemonMoves(switchOutTarget, allyPokemon);
}
// End battle if no enemies are active and enemy wasn't already KO'd (kos do )
// End battle if no enemies are active and enemy wasn't already KO'd
if (!allyPokemon?.isActive(true) && !switchOutTarget.isFainted()) {
globalScene.clearEnemyHeldItemModifiers();

View File

@ -1239,10 +1239,12 @@ export class MoveEffectAttr extends MoveAttr {
* @param user {@linkcode Pokemon} using the move
* @param target {@linkcode Pokemon} target of the move
* @param move {@linkcode Move} with this attribute
* @param args Set of unique arguments needed by this attribute
* @returns true if basic application of the ability attribute should be possible
* @param args - Any unique arguments needed by this attribute
* @returns `true` if basic application of the ability attribute should be possible.
* By default, checks that the target is not fainted and (for non self-targeting moves) not protected by an effect.
*/
canApply(user: Pokemon, target: Pokemon, move: Move, args?: any[]) {
// TODO: why do we check frenzy tag here?
return !! (this.selfTarget ? user.hp && !user.getTag(BattlerTagType.FRENZY) : target.hp)
&& (this.selfTarget || !target.getTag(BattlerTagType.PROTECTED) ||
move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target }));
@ -6239,7 +6241,7 @@ export class ForceSwitchOutAttr extends ForceSwitch(MoveEffectAttr) {
}
getCondition(): MoveConditionFunc {
// Damaging switch moves should not "fail" _per se_ upon a failed switch -
// Damaging switch moves should not "fail" _per se_ upon an unsuccessful switch -
// they still succeed and deal damage (but just without actually switching out).
return (user, target, move) => (move.category !== MoveCategory.STATUS || this.getSwitchOutCondition()(user, target, move));
}

View File

@ -6326,9 +6326,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* @param hideInfo - Whether to play the animation to hide the Pokemon's info container; default `true`.
* @param destroy - Whether to destroy this Pokemon once it leaves the field; default `false`
* @remarks
* This **SHOULD NOT** be called when a `SummonPhase` or `SwitchSummonPhase` is already being added,
* which can lead to premature resetting of {@linkcode turnData} and {@linkcode summonData}.
* This **SHOULD NOT** be called with `clearEffects=true` when a `SummonPhase` or `SwitchSummonPhase` is already being added,
* both of which do so already and can lead to premature resetting of {@linkcode turnData} and {@linkcode summonData}.
*/
// TODO: Review where this is being called and where it is necessary to call it
leaveField(clearEffects = true, hideInfo = true, destroy = false) {
console.log(`leaveField called on Pokemon ${this.name}`)
this.resetSprite();

View File

@ -1788,6 +1788,10 @@ export class HitHealModifier extends PokemonHeldItemModifier {
}
const totalDmgDealt = pokemon.turnData.lastMoveDamageDealt.reduce((r, d) => r + d, 0);
if (totalDmgDealt === 0) {
return false;
}
globalScene.unshiftPhase(
new PokemonHealPhase(
pokemon.getBattlerIndex(),

View File

@ -61,8 +61,6 @@ export class FaintPhase extends PokemonPhase {
faintPokemon.getTag(BattlerTagType.GRUDGE)?.lapse(faintPokemon, BattlerTagLapseType.CUSTOM, this.source);
}
faintPokemon.resetSummonData();
if (!this.preventInstantRevive) {
const instantReviveModifier = globalScene.applyModifier(
PokemonInstantReviveModifier,
@ -71,6 +69,7 @@ export class FaintPhase extends PokemonPhase {
) as PokemonInstantReviveModifier;
if (instantReviveModifier) {
faintPokemon.resetSummonData();
faintPokemon.loseHeldItem(instantReviveModifier);
globalScene.updateModifiers(this.player);
return this.end();

View File

@ -3,7 +3,6 @@ import { globalScene } from "#app/global-scene";
import {
AddSecondStrikeAbAttr,
AlwaysHitAbAttr,
applyAbAttrs,
applyPostAttackAbAttrs,
applyPostDamageAbAttrs,
applyPostDefendAbAttrs,
@ -79,7 +78,6 @@ import type Move from "#app/data/moves/move";
import { isFieldTargeted } from "#app/data/moves/move-utils";
import { FaintPhase } from "./faint-phase";
import { DamageAchv } from "#app/system/achv";
import { userInfo } from "node:os";
type HitCheckEntry = [HitCheckResult, TypeDamageMultiplier];
@ -767,7 +765,6 @@ export class MoveEffectPhase extends PokemonPhase {
* - Triggering form changes and emergency exit / wimp out if this is the last hit
*
* @param target - the {@linkcode Pokemon} hit by this phase's move.
* @param targetIndex - The index of the target (used to update damage dealt amounts)
* @param effectiveness - The effectiveness of the move (as previously evaluated in {@linkcode hitCheck})
* @param firstTarget - Whether this is the first target successfully struck by the move
*/
@ -813,7 +810,7 @@ export class MoveEffectPhase extends PokemonPhase {
*/
applyMoveAttrs(StatChangeBeforeDmgCalcAttr, user, target, this.move);
const { result: result, damage: dmg } = target.getAttackDamage({
const { result, damage: dmg } = target.getAttackDamage({
source: user,
move: this.move,
ignoreAbility: false,
@ -852,7 +849,7 @@ export class MoveEffectPhase extends PokemonPhase {
? 0
: target.damageAndUpdate(dmg, {
result: result as DamageResult,
ignoreFaintPhase: true,
ignoreFaintPhase: true, // ignore faint phase so we can handle it ourselves
ignoreSegments: isOneHitKo,
isCritical,
});

View File

@ -43,7 +43,7 @@ import { CommonAnimPhase } from "#app/phases/common-anim-phase";
import { MoveChargePhase } from "#app/phases/move-charge-phase";
import { MoveEffectPhase } from "#app/phases/move-effect-phase";
import { MoveEndPhase } from "#app/phases/move-end-phase";
import { getEnumValues, NumberHolder } from "#app/utils/common";
import { NumberHolder } from "#app/utils/common";
import { Abilities } from "#enums/abilities";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerTagType } from "#enums/battler-tag-type";
@ -160,7 +160,8 @@ export class MovePhase extends BattlePhase {
}
this.pokemon.turnData.acted = true;
this.pokemon.turnData.lastMoveDamageDealt = Array(Math.max(...getEnumValues(BattlerIndex))).fill(0);
// TODO: Increase this if triple battles are added
this.pokemon.turnData.lastMoveDamageDealt = Array(4).fill(0);
// Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats)
if (this.followUp) {

View File

@ -36,8 +36,8 @@ export class SwitchPhase extends BattlePhase {
start() {
super.start();
// Failsafe: skip modal switches if impossible (no eligible party members in reserve).
if (this.isModal && globalScene.getBackupPartyMemberIndices(true).length === 0) {
// Skip modal switch if impossible (no remaining party members that aren't in battle)
if (this.isModal && !globalScene.getPlayerParty().filter(p => p.isAllowedInBattle() && !p.isActive(true)).length) {
return super.end();
}
@ -53,10 +53,9 @@ export class SwitchPhase extends BattlePhase {
}
// Check if there is any space still on field.
// TODO: Do we need this?
if (
this.isModal &&
globalScene.getPlayerField().filter(p => p.isActive(true)).length > globalScene.currentBattle.getBattlerCount()
globalScene.getPlayerField().filter(p => p.isActive(true)).length >= globalScene.currentBattle.getBattlerCount()
) {
return super.end();
}

View File

@ -45,6 +45,15 @@ export class SwitchSummonPhase extends SummonPhase {
}
preSummon(): void {
const switchOutPokemon = this.getPokemon();
// if the target is still on-field, remove it and/or hide its info container.
// Effects are kept to be transferred to the new Pokemon if applicable
// TODO: Make moves that switch out pokemon defer to this phase
if (switchOutPokemon.isOnField()) {
switchOutPokemon.leaveField(false, switchOutPokemon.getBattleInfo()?.visible);
}
if (!this.player) {
if (this.slotIndex === -1) {
//@ts-ignore
@ -71,13 +80,12 @@ export class SwitchSummonPhase extends SummonPhase {
return;
}
const lastPokemon = this.getPokemon();
(this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach(enemyPokemon =>
enemyPokemon.removeTagsBySourceId(lastPokemon.id),
enemyPokemon.removeTagsBySourceId(switchOutPokemon.id),
);
if (this.switchType === SwitchType.SWITCH || this.switchType === SwitchType.INITIAL_SWITCH) {
const substitute = lastPokemon.getTag(SubstituteTag);
const substitute = switchOutPokemon.getTag(SubstituteTag);
if (substitute) {
globalScene.tweens.add({
targets: substitute.sprite,
@ -92,25 +100,26 @@ export class SwitchSummonPhase extends SummonPhase {
globalScene.ui.showText(
this.player
? i18next.t("battle:playerComeBack", {
pokemonName: getPokemonNameWithAffix(lastPokemon),
pokemonName: getPokemonNameWithAffix(switchOutPokemon),
})
: i18next.t("battle:trainerComeBack", {
trainerName: globalScene.currentBattle.trainer?.getName(
!(this.fieldIndex % 2) ? TrainerSlot.TRAINER : TrainerSlot.TRAINER_PARTNER,
),
pokemonName: lastPokemon.getNameToRender(),
pokemonName: switchOutPokemon.getNameToRender(),
}),
);
globalScene.playSound("se/pb_rel");
lastPokemon.hideInfo();
lastPokemon.tint(getPokeballTintColor(lastPokemon.getPokeball(true)), 1, 250, "Sine.easeIn");
switchOutPokemon.hideInfo();
switchOutPokemon.tint(getPokeballTintColor(switchOutPokemon.getPokeball(true)), 1, 250, "Sine.easeIn");
globalScene.tweens.add({
targets: lastPokemon,
targets: switchOutPokemon,
duration: 250,
ease: "Sine.easeIn",
scale: 0.5,
onComplete: () => {
globalScene.time.delayedCall(750, () => this.switchAndSummon());
switchOutPokemon.leaveField(this.switchType === SwitchType.SWITCH, false);
},
});
}
@ -161,17 +170,9 @@ export class SwitchSummonPhase extends SummonPhase {
}
}
// Swap around the 2 pokemon's party positions and play an animation to send in the new pokemon.
party[this.slotIndex] = this.lastPokemon;
party[this.fieldIndex] = switchedInPokemon;
const showTextAndSummon = () => {
// We don't reset temp effects here as we need to transfer them to tne new pokemon
// TODO: When should this remove the info container?
// Force switch moves did it prior
this.lastPokemon.leaveField(
![SwitchType.BATON_PASS, SwitchType.SHED_TAIL].includes(this.switchType),
this.doReturn,
);
globalScene.ui.showText(
this.player
? i18next.t("battle:playerGo", {

View File

@ -14,6 +14,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
import { BattleType } from "#enums/battle-type";
import { HitResult } from "#app/field/pokemon";
import type { ModifierOverride } from "#app/modifier/modifier-type";
import type { SwitchSummonPhase } from "#app/phases/switch-summon-phase";
describe("Abilities - Wimp Out", () => {
let phaserGame: Phaser.Game;
@ -64,7 +65,7 @@ describe("Abilities - Wimp Out", () => {
expect(pokemon1.species.speciesId).toBe(Species.WIMPOD);
expect(pokemon1.isFainted()).toBe(false);
expect(pokemon1.getHpRatio()).toBeLessThan(0.5);
expect(pokemon1.getHpRatio(true)).toBeLessThan(0.5);
}
it("should switch the user out when falling below half HP, canceling its subsequent moves", async () => {
@ -86,8 +87,11 @@ describe("Abilities - Wimp Out", () => {
expect(wimpod.turnData.acted).toBe(false);
});
it("should not trigger if user faints from damage", async () => {
game.override.enemyMoveset(Moves.BRAVE_BIRD).enemyLevel(1000);
it("should not trigger if user faints from damage and is revived", async () => {
game.override
.startingHeldItems([{ name: "REVIVER_SEED", count: 1 }])
.enemyMoveset(Moves.BRAVE_BIRD)
.enemyLevel(1000);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
const wimpod = game.scene.getPlayerPokemon()!;
@ -95,9 +99,12 @@ describe("Abilities - Wimp Out", () => {
game.move.select(Moves.SPLASH);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
await game.toNextTurn();
expect(wimpod.isFainted()).toBe(true);
expect(wimpod.isFainted()).toBe(false);
expect(wimpod.isOnField()).toBe(true);
expect(wimpod.getHpRatio()).toBeCloseTo(0.5);
expect(wimpod.getHeldItems()).toHaveLength(0);
expect(wimpod.waveData.abilitiesApplied).not.toContain(Abilities.WIMP_OUT);
});
@ -185,34 +192,41 @@ describe("Abilities - Wimp Out", () => {
});
it("should block U-turn or Volt Switch on activation", async () => {
game.override.enemyMoveset(Moves.U_TURN);
game.override.battleType(BattleType.TRAINER).enemyMoveset(Moves.U_TURN);
await game.classicMode.startBattle([Species.WIMPOD, Species.TYRUNT]);
const ninjask = game.scene.getEnemyPokemon()!;
game.move.select(Moves.SPLASH);
game.doSelectPartyPokemon(1);
await game.phaseInterceptor.to("TurnEndPhase");
const enemyPokemon = game.scene.getEnemyPokemon()!;
const hasFled = enemyPokemon.switchOutStatus;
expect(hasFled).toBe(false);
confirmSwitch();
expect(ninjask.isOnField()).toBe(true);
});
it("should not block U-turn or Volt Switch if not activated", async () => {
game.override.enemyMoveset(Moves.U_TURN).battleType(BattleType.TRAINER);
game.override.battleType(BattleType.TRAINER).enemyMoveset(Moves.U_TURN).battleType(BattleType.TRAINER);
await game.classicMode.startBattle([Species.GOLISOPOD, Species.TYRUNT]);
const ninjask1 = game.scene.getEnemyPokemon()!;
vi.spyOn(game.scene.getPlayerPokemon()!, "getAttackDamage").mockReturnValue({
const wimpod = game.scene.getPlayerPokemon()!;
const ninjask = game.scene.getEnemyPokemon()!;
// force enemy u turn to do 1 dmg
vi.spyOn(wimpod, "getAttackDamage").mockReturnValue({
cancelled: false,
damage: 1,
result: HitResult.EFFECTIVE,
});
game.move.select(Moves.SPLASH);
await game.phaseInterceptor.to("SwitchSummonPhase", false);
const switchSummonPhase = game.scene.getCurrentPhase() as SwitchSummonPhase;
expect(switchSummonPhase.getPokemon()).toBe(ninjask);
await game.phaseInterceptor.to("TurnEndPhase");
expect(ninjask1.isOnField()).toBe(true);
expect(wimpod.isOnField()).toBe(true);
expect(ninjask.isOnField()).toBe(false);
});
it("should not activate from Dragon Tail and Circle Throw", async () => {
@ -242,7 +256,7 @@ describe("Abilities - Wimp Out", () => {
{ type: "status", enemyMove: Moves.TOXIC },
{ type: "Ghost-type Curse", enemyMove: Moves.CURSE },
{ type: "Salt Cure", enemyMove: Moves.SALT_CURE },
{ type: "partial trapping moves", enemyMove: Moves.WHIRLPOOL }, // no guard passive makes this guaranteed
{ type: "partial trapping moves", enemyMove: Moves.WHIRLPOOL }, // no guard passive makes this 100% accurate
{ type: "Leech Seed", enemyMove: Moves.LEECH_SEED },
{ type: "Powder", playerMove: Moves.EMBER, enemyMove: Moves.POWDER },
{ type: "Nightmare", playerPassive: Abilities.COMATOSE, enemyMove: Moves.NIGHTMARE },
@ -254,10 +268,11 @@ describe("Abilities - Wimp Out", () => {
playerMove = Moves.SPLASH,
playerPassive = Abilities.NONE,
enemyMove = Moves.SPLASH,
enemyAbility = Abilities.BALL_FETCH,
enemyAbility = Abilities.STURDY,
}) => {
game.override
.moveset(playerMove)
.enemyLevel(1)
.passiveAbility(playerPassive)
.enemySpecies(Species.GASTLY)
.enemyMoveset(enemyMove)
@ -266,7 +281,7 @@ describe("Abilities - Wimp Out", () => {
const wimpod = game.scene.getPlayerPokemon()!;
expect(wimpod).toBeDefined();
wimpod.hp = toDmgValue(wimpod.getMaxHp() / 2 + 5);
wimpod.hp = toDmgValue(wimpod.getMaxHp() / 2 + 2);
// mock enemy attack damage func to only do 1 dmg (for whirlpool)
vi.spyOn(wimpod, "getAttackDamage").mockReturnValueOnce({
cancelled: false,
@ -342,18 +357,16 @@ describe("Abilities - Wimp Out", () => {
game.move.select(Moves.SUBSTITUTE);
await game.forceEnemyMove(Moves.TIDY_UP);
game.doSelectPartyPokemon(1);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
confirmNoSwitch();
// Turn 2: get back enough HP that substitute doesn't put us under
wimpod.hp = wimpod.getMaxHp() * 0.78;
wimpod.hp = wimpod.getMaxHp() * 0.8;
game.move.select(Moves.SUBSTITUTE);
game.doSelectPartyPokemon(1);
await game.forceEnemyMove(Moves.ROUND);
game.doSelectPartyPokemon(1);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("TurnEndPhase");
@ -413,7 +426,7 @@ describe("Abilities - Wimp Out", () => {
game.move.select(Moves.SPLASH);
await game.toNextTurn();
confirmNoSwitch();
expect(wimpod.isOnField()).toBe(true);
expect(wimpod.getHpRatio()).toBeCloseTo(0.51);
});
@ -548,7 +561,7 @@ describe("Abilities - Wimp Out", () => {
await game.classicMode.startBattle([Species.REGIDRAGO, Species.MAGIKARP]);
// turn 1
// turn 1 - 1st wimpod faints while the 2nd one flees
game.move.select(Moves.DRAGON_ENERGY, 0);
game.move.select(Moves.SPLASH, 1);
await game.forceEnemyMove(Moves.SPLASH);

View File

@ -1,10 +1,10 @@
import { BattlerIndex } from "#app/battle";
import { allMoves } from "#app/data/moves/move";
import { BattlerTagType } from "#app/enums/battler-tag-type";
import type { PokemonInstantReviveModifier } from "#app/modifier/modifier";
import { HitResult } from "#app/field/pokemon";
import { PokemonInstantReviveModifier } from "#app/modifier/modifier";
import { Abilities } from "#enums/abilities";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import { Stat } from "#enums/stat";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@ -26,77 +26,106 @@ describe("Items - Reviver Seed", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([Moves.SPLASH, Moves.TACKLE, Moves.ENDURE])
.moveset([Moves.SPLASH, Moves.TACKLE, Moves.LUMINA_CRASH])
.ability(Abilities.BALL_FETCH)
.battleStyle("single")
.disableCrits()
.enemySpecies(Species.MAGIKARP)
.enemyAbility(Abilities.BALL_FETCH)
.enemyAbility(Abilities.NO_GUARD)
.startingHeldItems([{ name: "REVIVER_SEED" }])
.enemyHeldItems([{ name: "REVIVER_SEED" }])
.enemyMoveset(Moves.SPLASH);
vi.spyOn(allMoves[Moves.SHEER_COLD], "accuracy", "get").mockReturnValue(100);
vi.spyOn(allMoves[Moves.LEECH_SEED], "accuracy", "get").mockReturnValue(100);
vi.spyOn(allMoves[Moves.WHIRLPOOL], "accuracy", "get").mockReturnValue(100);
vi.spyOn(allMoves[Moves.WILL_O_WISP], "accuracy", "get").mockReturnValue(100);
});
it.each([
{ moveType: "Special Move", move: Moves.WATER_GUN },
{ moveType: "Physical Move", move: Moves.TACKLE },
{ moveType: "Fixed Damage Move", move: Moves.SEISMIC_TOSS },
{ moveType: "Final Gambit", move: Moves.FINAL_GAMBIT },
{ moveType: "Counter", move: Moves.COUNTER },
{ moveType: "OHKO", move: Moves.SHEER_COLD },
])("should activate the holder's reviver seed from a $moveType", async ({ move }) => {
game.override.enemyLevel(100).startingLevel(1).enemyMoveset(move);
await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]);
const player = game.scene.getPlayerPokemon()!;
player.damageAndUpdate(player.hp - 1);
const reviverSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier;
vi.spyOn(reviverSeed, "apply");
game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to("BerryPhase");
expect(player.isFainted()).toBeFalsy();
});
it("should activate the holder's reviver seed from confusion self-hit", async () => {
game.override.enemyLevel(1).startingLevel(100).enemyMoveset(Moves.SPLASH);
await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]);
const player = game.scene.getPlayerPokemon()!;
player.damageAndUpdate(player.hp - 1);
player.addTag(BattlerTagType.CONFUSED, 3);
const reviverSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier;
vi.spyOn(reviverSeed, "apply");
vi.spyOn(player, "randBattleSeedInt").mockReturnValue(0); // Force confusion self-hit
game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to("BerryPhase");
expect(player.isFainted()).toBeFalsy();
});
// Damaging opponents tests
it.each([
{ moveType: "Damaging Move Chip Damage", move: Moves.SALT_CURE },
{ moveType: "Chip Damage", move: Moves.LEECH_SEED },
{ moveType: "Trapping Chip Damage", move: Moves.WHIRLPOOL },
{ moveType: "Status Effect Damage", move: Moves.WILL_O_WISP },
{ moveType: "Weather", move: Moves.SANDSTORM },
])("should not activate the holder's reviver seed from $moveType", async ({ move }) => {
game.override
.enemyLevel(1)
.enemyMoveset(Moves.SPLASH)
.startingLevel(100)
.enemySpecies(Species.MAGIKARP)
.moveset(move)
.enemyMoveset(Moves.ENDURE);
.enemyLevel(100); // makes hp tests more accurate due to rounding
});
it("should be consumed upon fainting to revive the holder, removing temporary effects and healing to 50% max HP", async () => {
await game.classicMode.startBattle([Species.MAGIKARP]);
const enemy = game.scene.getEnemyPokemon()!;
enemy.hp = 1;
enemy.setStatStage(Stat.ATK, 6);
expect(enemy.getHeldItems()[0]).toBeInstanceOf(PokemonInstantReviveModifier);
game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to("TurnEndPhase");
// Enemy ate seed, was revived and healed to half HP, clearing its attack boost at the same time.
expect(enemy.isFainted()).toBeFalsy();
expect(enemy.getHpRatio()).toBeCloseTo(0.5);
expect(enemy.getHeldItems()[0]).toBeUndefined();
expect(enemy.getStatStage(Stat.ATK)).toBe(0);
expect(enemy.turnData.acted).toBe(true);
});
it("should nullify move effects on the killing blow and interrupt multi hits", async () => {
// Give player a 4 hit lumina crash that lowers spdef by 2 stages per hit
game.override.ability(Abilities.PARENTAL_BOND).startingHeldItems([
{ name: "REVIVER_SEED", count: 1 },
{ name: "MULTI_LENS", count: 2 },
]);
await game.classicMode.startBattle([Species.MAGIKARP]);
// give enemy 3 hp, dying 3 hits into the move
const enemy = game.scene.getEnemyPokemon()!;
enemy.hp = 3;
vi.spyOn(enemy, "getAttackDamage").mockReturnValue({ cancelled: false, damage: 1, result: HitResult.EFFECTIVE });
game.move.select(Moves.LUMINA_CRASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("FaintPhase", false);
expect(enemy.getStatStage(Stat.SPDEF)).toBe(-4); // killing hit effect got nullified due to fainting the target
expect(enemy.getAttackDamage).toHaveBeenCalledTimes(3);
await game.phaseInterceptor.to("TurnEndPhase");
// Attack was cut short due to lack of targets, after which the enemy was revived and their stat stages reset
expect(enemy.isFainted()).toBeFalsy();
expect(enemy.getStatStage(Stat.SPDEF)).toBe(0);
expect(enemy.getHpRatio()).toBeCloseTo(0.5);
expect(enemy.getHeldItems()[0]).toBeUndefined();
const player = game.scene.getPlayerPokemon()!;
expect(player.turnData.hitsLeft).toBe(1);
});
it.each([
{ moveType: "Special Moves", move: Moves.WATER_GUN },
{ moveType: "Physical Moves", move: Moves.TACKLE },
{ moveType: "Fixed Damage Moves", move: Moves.SEISMIC_TOSS },
{ moveType: "Final Gambit", move: Moves.FINAL_GAMBIT },
{ moveType: "Counter Moves", move: Moves.COUNTER },
{ moveType: "OHKOs", move: Moves.SHEER_COLD },
{ moveType: "Confusion Self-hits", move: Moves.CONFUSE_RAY },
])("should activate from $moveType", async ({ move }) => {
game.override.enemyMoveset(move).confusionActivation(true);
await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]);
const player = game.scene.getPlayerPokemon()!;
player.hp = 1;
const reviverSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier;
const seedSpy = vi.spyOn(reviverSeed, "apply");
game.move.select(Moves.TACKLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(player.isFainted()).toBe(false);
expect(seedSpy).toHaveBeenCalled();
});
// Damaging tests
it.each([
{ moveType: "Salt Cure", move: Moves.SALT_CURE },
{ moveType: "Leech Seed", move: Moves.LEECH_SEED },
{ moveType: "Partial Trapping Move", move: Moves.WHIRLPOOL },
{ moveType: "Status Effect", move: Moves.WILL_O_WISP },
{ moveType: "Weather", move: Moves.SANDSTORM },
])("should not activate from $moveType damage", async ({ move }) => {
game.override.moveset(move).enemyMoveset(Moves.ENDURE);
await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]);
const enemy = game.scene.getEnemyPokemon()!;
enemy.damageAndUpdate(enemy.hp - 1);
enemy.hp = 1;
game.move.select(move);
await game.phaseInterceptor.to("TurnEndPhase");
@ -107,31 +136,26 @@ describe("Items - Reviver Seed", () => {
// Self-damage tests
it.each([
{ moveType: "Recoil", move: Moves.DOUBLE_EDGE },
{ moveType: "Self-KO", move: Moves.EXPLOSION },
{ moveType: "Self-Deduction", move: Moves.CURSE },
{ moveType: "Sacrificial", move: Moves.EXPLOSION },
{ moveType: "Ghost-type Curse", move: Moves.CURSE },
{ moveType: "Liquid Ooze", move: Moves.GIGA_DRAIN },
])("should not activate the holder's reviver seed from $moveType", async ({ move }) => {
game.override
.enemyLevel(100)
.startingLevel(1)
.enemySpecies(Species.MAGIKARP)
.moveset(move)
.enemyAbility(Abilities.LIQUID_OOZE)
.enemyMoveset(Moves.SPLASH);
])("should not activate from $moveType self-damage", async ({ move }) => {
game.override.moveset(move).enemyAbility(Abilities.LIQUID_OOZE);
await game.classicMode.startBattle([Species.GASTLY, Species.FEEBAS]);
const player = game.scene.getPlayerPokemon()!;
player.damageAndUpdate(player.hp - 1);
player.hp = 1;
const playerSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier;
vi.spyOn(playerSeed, "apply");
const seedSpy = vi.spyOn(playerSeed, "apply");
game.move.select(move);
await game.phaseInterceptor.to("TurnEndPhase");
expect(player.isFainted()).toBeTruthy();
expect(seedSpy).not.toHaveBeenCalled();
});
it("should not activate the holder's reviver seed from Destiny Bond fainting", async () => {
it("should not activate from Destiny Bond fainting", async () => {
game.override
.enemyLevel(100)
.startingLevel(1)
@ -141,7 +165,7 @@ describe("Items - Reviver Seed", () => {
.enemyMoveset(Moves.TACKLE);
await game.classicMode.startBattle([Species.MAGIKARP, Species.FEEBAS]);
const player = game.scene.getPlayerPokemon()!;
player.damageAndUpdate(player.hp - 1);
player.hp = 1;
const enemy = game.scene.getEnemyPokemon()!;
game.move.select(Moves.DESTINY_BOND);

View File

@ -156,7 +156,7 @@ describe("Moves - Dragon Tail", () => {
expect(enemySecPokemon.hp).toBeLessThan(enemySecPokemon.getMaxHp());
});
it("should not switch out a target with suction cups", async () => {
it("should not switch out a target with suction cups, unless the user has Mold Breaker", async () => {
game.override.enemyAbility(Abilities.SUCTION_CUPS);
await game.classicMode.startBattle([Species.REGIELEKI]);
@ -167,6 +167,16 @@ describe("Moves - Dragon Tail", () => {
expect(enemy.isOnField()).toBe(true);
expect(enemy.isFullHp()).toBe(false);
// Turn 2: Mold Breaker should ignore switch blocking ability and switch out the target
game.override.ability(Abilities.MOLD_BREAKER);
enemy.hp = enemy.getMaxHp();
game.move.select(Moves.DRAGON_TAIL);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.isOnField()).toBe(false);
expect(enemy.isFullHp()).toBe(false);
});
it("should not switch out a Commanded Dondozo", async () => {
@ -203,43 +213,47 @@ describe("Moves - Dragon Tail", () => {
expect(game.scene.getEnemyField().length).toBe(1);
});
it("should not cause a softlock when activating an opponent trainer's reviver seed", async () => {
it("should neither switch nor softlock when activating an opponent's reviver seed", async () => {
game.override
.startingWave(5)
.battleType(BattleType.TRAINER)
.enemyHeldItems([{ name: "REVIVER_SEED" }])
.startingLevel(1000); // To make sure Dragon Tail KO's the opponent
.startingLevel(1000); // make sure Dragon Tail KO's the opponent
await game.classicMode.startBattle([Species.DRATINI]);
game.move.select(Moves.DRAGON_TAIL);
const [wailord1, wailord2] = game.scene.getEnemyParty()!;
expect(wailord1).toBeDefined();
expect(wailord2).toBeDefined();
game.move.select(Moves.DRAGON_TAIL);
await game.toNextTurn();
// Make sure the enemy field is not empty and has a revived Pokemon
const enemy = game.scene.getEnemyPokemon()!;
expect(enemy).toBeDefined();
expect(enemy.hp).toBe(Math.floor(enemy.getMaxHp() / 2));
expect(game.scene.getEnemyField().length).toBe(1);
// Wailord should have consumed the reviver seed and stayed on field
expect(wailord1.isOnField()).toBe(true);
expect(wailord1.getHpRatio()).toBeCloseTo(0.5);
expect(wailord1.getHeldItems()).toHaveLength(0);
expect(wailord2.isOnField()).toBe(false);
});
it("should not cause a softlock when activating a player's reviver seed", async () => {
it("should neither switch nor softlock when activating a player's reviver seed", async () => {
game.override
.startingHeldItems([{ name: "REVIVER_SEED" }])
.enemyMoveset(Moves.DRAGON_TAIL)
.enemyLevel(1000); // To make sure Dragon Tail KO's the player
await game.classicMode.startBattle([Species.DRATINI, Species.BULBASAUR]);
.enemyLevel(1000); // make sure Dragon Tail KO's the player
await game.classicMode.startBattle([Species.BLISSEY, Species.BULBASAUR]);
const [blissey, bulbasaur] = game.scene.getPlayerParty();
game.move.select(Moves.SPLASH);
await game.toNextTurn();
// Make sure the player's field is not empty and has a revived Pokemon
const dratini = game.scene.getPlayerPokemon()!;
expect(dratini).toBeDefined();
expect(dratini.hp).toBe(Math.floor(dratini.getMaxHp() / 2));
expect(game.scene.getPlayerField().length).toBe(1);
// dratini should have consumed the reviver seed and stayed on field
expect(blissey.isOnField()).toBe(true);
expect(blissey.getHpRatio()).toBeCloseTo(0.5);
expect(blissey.getHeldItems()).toHaveLength(0);
expect(bulbasaur.isOnField()).toBe(false);
});
it("should force switches randomly", async () => {
it("should force switches to a random off-field pokemon", async () => {
game.override.enemyMoveset(Moves.DRAGON_TAIL).startingLevel(100).enemyLevel(1);
await game.classicMode.startBattle([Species.BULBASAUR, Species.CHARMANDER, Species.SQUIRTLE]);
@ -279,6 +293,7 @@ describe("Moves - Dragon Tail", () => {
const [lapras, eevee, toxapex, primarina] = game.scene.getPlayerParty();
expect(toxapex).toBeDefined();
toxapex.hp = 0;
// Mock an RNG call to switch to the first eligible pokemon.
// Eevee is ineligible and Toxapex is fainted, so it should proc on Primarina instead
@ -286,7 +301,6 @@ describe("Moves - Dragon Tail", () => {
return min;
});
game.move.select(Moves.SPLASH);
await game.killPokemon(toxapex);
await game.toNextTurn();
expect(lapras.isOnField()).toBe(false);
@ -309,14 +323,15 @@ describe("Moves - Dragon Tail", () => {
game.move.select(Moves.SPLASH, BattlerIndex.PLAYER_2);
await game.forceEnemyMove(Moves.DRAGON_TAIL, BattlerIndex.PLAYER);
await game.forceEnemyMove(Moves.DRAGON_TAIL, BattlerIndex.PLAYER_2);
await game.killPokemon(cloyster);
await game.killPokemon(kyogre);
game.doSelectPartyPokemon(3);
await game.toNextTurn();
// Eevee is ineligble due to challenge and cloyster is fainted, leaving no backup pokemon able to switch in
// Eevee is ineligble due to challenge and kyogre is fainted, leaving no backup pokemon able to switch in
expect(lapras.isOnField()).toBe(true);
expect(kyogre.isOnField()).toBe(true);
expect(kyogre.isOnField()).toBe(false);
expect(eevee.isOnField()).toBe(false);
expect(cloyster.isOnField()).toBe(false);
expect(cloyster.isOnField()).toBe(true);
expect(lapras.getInverseHp()).toBeGreaterThan(0);
expect(kyogre.getInverseHp()).toBeGreaterThan(0);
expect(game.scene.getBackupPartyMemberIndices(true)).toHaveLength(0);