Fix #2735: Hazard moves incorrectly require targets (#5635)

* Fix #2735: Hazard moves incorrectly require targets
Hazard moves should no longer require targets to successfully execute

* Apply suggestions from code review made by NightKev

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

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
This commit is contained in:
Diogo Cruz Diniz 2025-04-07 14:50:52 +01:00 committed by GitHub
parent 1e6ceb5581
commit 0479b9dfcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 236 additions and 206 deletions

View File

@ -1428,7 +1428,8 @@ export class MoveAnim extends BattleAnim {
public move: Moves;
constructor(move: Moves, user: Pokemon, target: BattlerIndex, playOnEmptyField = false) {
super(user, globalScene.getField()[target], playOnEmptyField);
// Set target to the user pokemon if no target is found to avoid crashes
super(user, globalScene.getField()[target] ?? user, playOnEmptyField);
this.move = move;
}

View File

@ -5928,7 +5928,7 @@ export class AddArenaTagAttr extends MoveEffectAttr {
}
if ((move.chance < 0 || move.chance === 100 || user.randSeedInt(100) < move.chance) && user.getLastXMoves(1)[0]?.result === MoveResult.SUCCESS) {
const side = (this.selfSideTarget ? user : target).isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
const side = ((this.selfSideTarget ? user : target).isPlayer() !== (move.hasAttr(AddArenaTrapTagAttr) && target === user)) ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
globalScene.arena.addTag(this.tagType, this.turnCount, move.id, user.id, side);
return true;
}
@ -5977,7 +5977,7 @@ export class RemoveArenaTagsAttr extends MoveEffectAttr {
export class AddArenaTrapTagAttr extends AddArenaTagAttr {
getCondition(): MoveConditionFunc {
return (user, target, move) => {
const side = (this.selfSideTarget ? user : target).isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
const side = (this.selfSideTarget !== user.isPlayer()) ? ArenaTagSide.ENEMY : ArenaTagSide.PLAYER;
const tag = globalScene.arena.getTagOnSide(this.tagType, side) as ArenaTrapTag;
if (!tag) {
return true;

View File

@ -26,6 +26,7 @@ import {
} from "#app/data/battler-tags";
import type { MoveAttr } from "#app/data/moves/move";
import {
AddArenaTrapTagAttr,
applyFilteredMoveAttrs,
applyMoveAttrs,
AttackMove,
@ -209,12 +210,12 @@ export class MoveEffectPhase extends PokemonPhase {
targets.some(t => t.hasAbilityWithAttr(ReflectStatusMoveAbAttr) || !!t.getTag(BattlerTagType.MAGIC_COAT));
/**
* If no targets are left for the move to hit (FAIL), or the invoked move is non-reflectable, single-target
* If no targets are left for the move to hit and it is not a hazard move (FAIL), or the invoked move is non-reflectable, single-target
* (and not random target) and failed the hit check against its target (MISS), log the move
* as FAILed or MISSed (depending on the conditions above) and end this phase.
*/
if (
!hasActiveTargets ||
(!hasActiveTargets && !move.hasAttr(AddArenaTrapTagAttr)) ||
(!mayBounce &&
!move.hasAttr(VariableTargetAttr) &&
!move.isMultiTarget() &&
@ -239,18 +240,28 @@ export class MoveEffectPhase extends PokemonPhase {
return this.end();
}
const playOnEmptyField = globalScene.currentBattle?.mysteryEncounter?.hasBattleAnimationsWithoutTargets ?? false;
// Move animation only needs one target
new MoveAnim(move.id as Moves, user, this.getFirstTarget()!.getBattlerIndex(), playOnEmptyField).play(
move.hitsSubstitute(user, this.getFirstTarget()!),
() => {
/** Has the move successfully hit a target (for damage) yet? */
let hasHit = false;
const playOnEmptyField =
(globalScene.currentBattle?.mysteryEncounter?.hasBattleAnimationsWithoutTargets ?? false) ||
(!hasActiveTargets && move.hasAttr(AddArenaTrapTagAttr));
// Move animation only needs one target. The attacker is used as a fallback.
new MoveAnim(
move.id as Moves,
user,
this.getFirstTarget()?.getBattlerIndex() ?? BattlerIndex.ATTACKER,
playOnEmptyField,
).play(move.hitsSubstitute(user, this.getFirstTarget()!), () => {
/** Has the move successfully hit a target (for damage) yet? */
let hasHit = false;
// Prevent ENEMY_SIDE targeted moves from occurring twice in double battles
// and check which target will magic bounce.
const trueTargets: Pokemon[] =
move.moveTarget !== MoveTarget.ENEMY_SIDE
// Prevent ENEMY_SIDE targeted moves from occurring twice in double battles
// and check which target will magic bounce.
// In the event that the move is a hazard move, there may be no target and the move should still succeed.
// In this case, the user is used as the "target" to prevent a crash.
// This should not affect normal execution of the move otherwise.
const trueTargets: Pokemon[] =
!hasActiveTargets && move.hasAttr(AddArenaTrapTagAttr)
? [user]
: move.moveTarget !== MoveTarget.ENEMY_SIDE
? targets
: (() => {
const magicCoatTargets = targets.filter(
@ -264,27 +275,27 @@ export class MoveEffectPhase extends PokemonPhase {
return [magicCoatTargets[0]];
})();
const queuedPhases: Phase[] = [];
for (const target of trueTargets) {
/** The {@linkcode ArenaTagSide} to which the target belongs */
const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
/** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */
const hasConditionalProtectApplied = new BooleanHolder(false);
/** Does the applied conditional protection bypass Protect-ignoring effects? */
const bypassIgnoreProtect = new BooleanHolder(false);
/** If the move is not targeting a Pokemon on the user's side, try to apply conditional protection effects */
if (!this.move.getMove().isAllyTarget()) {
globalScene.arena.applyTagsForSide(
ConditionalProtectTag,
targetSide,
false,
hasConditionalProtectApplied,
user,
target,
move.id,
bypassIgnoreProtect,
);
}
const queuedPhases: Phase[] = [];
for (const target of trueTargets) {
/** The {@linkcode ArenaTagSide} to which the target belongs */
const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
/** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */
const hasConditionalProtectApplied = new BooleanHolder(false);
/** Does the applied conditional protection bypass Protect-ignoring effects? */
const bypassIgnoreProtect = new BooleanHolder(false);
/** If the move is not targeting a Pokemon on the user's side, try to apply conditional protection effects */
if (!this.move.getMove().isAllyTarget()) {
globalScene.arena.applyTagsForSide(
ConditionalProtectTag,
targetSide,
false,
hasConditionalProtectApplied,
user,
target,
move.id,
bypassIgnoreProtect,
);
}
/** Is the target protected by Protect, etc. or a relevant conditional protection effect? */
const isProtected =
@ -297,13 +308,13 @@ export class MoveEffectPhase extends PokemonPhase {
(this.move.getMove().category !== MoveCategory.STATUS &&
target.findTags(t => t instanceof DamageProtectedTag).find(t => target.lapseTag(t.tagType))));
/** Is the target hidden by the effects of its Commander ability? */
const isCommanding =
globalScene.currentBattle.double &&
target.getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon() === target;
/** Is the target hidden by the effects of its Commander ability? */
const isCommanding =
globalScene.currentBattle.double &&
target.getAlly()?.getTag(BattlerTagType.COMMANDED)?.getSourcePokemon() === target;
/** Is the target reflecting status moves from the magic coat move? */
const isReflecting = !!target.getTag(BattlerTagType.MAGIC_COAT);
/** Is the target reflecting status moves from the magic coat move? */
const isReflecting = !!target.getTag(BattlerTagType.MAGIC_COAT);
/** Is the target's magic bounce ability not ignored and able to reflect this move? */
const canMagicBounce =
@ -311,16 +322,16 @@ export class MoveEffectPhase extends PokemonPhase {
!move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_ABILITIES, user, target }) &&
target.hasAbilityWithAttr(ReflectStatusMoveAbAttr);
const semiInvulnerableTag = target.getTag(SemiInvulnerableTag);
const semiInvulnerableTag = target.getTag(SemiInvulnerableTag);
/** Is the target reflecting the effect, not protected, and not in an semi-invulnerable state?*/
const willBounce =
!isProtected &&
!this.reflected &&
!isCommanding &&
move.hasFlag(MoveFlags.REFLECTABLE) &&
(isReflecting || canMagicBounce) &&
!semiInvulnerableTag;
/** Is the target reflecting the effect, not protected, and not in an semi-invulnerable state?*/
const willBounce =
!isProtected &&
!this.reflected &&
!isCommanding &&
move.hasFlag(MoveFlags.REFLECTABLE) &&
(isReflecting || canMagicBounce) &&
!semiInvulnerableTag;
// If the move will bounce, then queue the bounce and move on to the next target
if (!target.switchOutStatus && willBounce) {
@ -338,171 +349,171 @@ export class MoveEffectPhase extends PokemonPhase {
queuedPhases.push(new HideAbilityPhase());
}
queuedPhases.push(
new MovePhase(target, newTargets, new PokemonMove(move.id, 0, 0, true), true, true, true),
queuedPhases.push(new MovePhase(target, newTargets, new PokemonMove(move.id, 0, 0, true), true, true, true));
continue;
}
/** Is the pokemon immune due to an ablility, and also not in a semi invulnerable state? */
const isImmune =
target.hasAbilityWithAttr(TypeImmunityAbAttr) &&
target.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move) &&
!semiInvulnerableTag;
/**
* If the move missed a target, stop all future hits against that target
* and move on to the next target (if there is one).
*/
if (
target.switchOutStatus ||
isCommanding ||
(!isImmune &&
!isProtected &&
!targetHitChecks[target.getBattlerIndex()] &&
!move.hasAttr(AddArenaTrapTagAttr))
) {
this.stopMultiHit(target);
if (!target.switchOutStatus) {
globalScene.queueMessage(
i18next.t("battle:attackMissed", {
pokemonNameWithAffix: getPokemonNameWithAffix(target),
}),
);
continue;
}
/** Is the pokemon immune due to an ablility, and also not in a semi invulnerable state? */
const isImmune =
target.hasAbilityWithAttr(TypeImmunityAbAttr) &&
target.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move) &&
!semiInvulnerableTag;
/**
* If the move missed a target, stop all future hits against that target
* and move on to the next target (if there is one).
*/
if (
target.switchOutStatus ||
isCommanding ||
(!isImmune && !isProtected && !targetHitChecks[target.getBattlerIndex()])
) {
this.stopMultiHit(target);
if (!target.switchOutStatus) {
globalScene.queueMessage(
i18next.t("battle:attackMissed", {
pokemonNameWithAffix: getPokemonNameWithAffix(target),
}),
);
}
if (moveHistoryEntry.result === MoveResult.PENDING) {
moveHistoryEntry.result = MoveResult.MISS;
}
user.pushMoveHistory(moveHistoryEntry);
applyMoveAttrs(MissEffectAttr, user, null, move);
continue;
}
/** Does this phase represent the invoked move's first strike? */
const firstHit = user.turnData.hitsLeft === user.turnData.hitCount;
// Only log the move's result on the first strike
if (firstHit) {
user.pushMoveHistory(moveHistoryEntry);
}
/**
* Since all fail/miss checks have applied, the move is considered successfully applied.
* It's worth noting that if the move has no effect or is protected against, this assignment
* is overwritten and the move is logged as a FAIL.
*/
moveHistoryEntry.result = MoveResult.SUCCESS;
/**
* Stores the result of applying the invoked move to the target.
* If the target is protected, the result is always `NO_EFFECT`.
* Otherwise, the hit result is based on type effectiveness, immunities,
* and other factors that may negate the attack or status application.
*
* Internally, the call to {@linkcode Pokemon.apply} is where damage is calculated
* (for attack moves) and the target's HP is updated. However, this isn't
* made visible to the user until the resulting {@linkcode DamagePhase}
* is invoked.
*/
const hitResult = !isProtected ? target.apply(user, move) : HitResult.NO_EFFECT;
/** Does {@linkcode hitResult} indicate that damage was dealt to the target? */
const dealsDamage = [
HitResult.EFFECTIVE,
HitResult.SUPER_EFFECTIVE,
HitResult.NOT_VERY_EFFECTIVE,
HitResult.ONE_HIT_KO,
].includes(hitResult);
/** Is this target the first one hit by the move on its current strike? */
const firstTarget = dealsDamage && !hasHit;
if (firstTarget) {
hasHit = true;
}
/**
* If the move has no effect on the target (i.e. the target is protected or immune),
* change the logged move result to FAIL.
*/
if (hitResult === HitResult.NO_EFFECT) {
moveHistoryEntry.result = MoveResult.FAIL;
}
/** Does this phase represent the invoked move's last strike? */
const lastHit = user.turnData.hitsLeft === 1 || !this.getFirstTarget()?.isActive();
/**
* If the user can change forms by using the invoked move,
* it only changes forms after the move's last hit
* (see Relic Song's interaction with Parental Bond when used by Meloetta).
*/
if (lastHit) {
globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger);
/**
* Multi-Lens, Multi Hit move and Parental Bond check for PostDamageAbAttr
* other damage source are calculated in damageAndUpdate in pokemon.ts
*/
if (user.turnData.hitCount > 1) {
applyPostDamageAbAttrs(PostDamageAbAttr, target, 0, target.hasPassive(), false, [], user);
}
}
applyFilteredMoveAttrs(
(attr: MoveAttr) =>
attr instanceof MoveEffectAttr &&
attr.trigger === MoveEffectTrigger.PRE_APPLY &&
(!attr.firstHitOnly || firstHit) &&
(!attr.lastHitOnly || lastHit) &&
hitResult !== HitResult.NO_EFFECT,
user,
target,
move,
);
if (hitResult !== HitResult.FAIL) {
this.applySelfTargetEffects(user, target, firstHit, lastHit);
if (hitResult !== HitResult.NO_EFFECT) {
this.applyPostApplyEffects(user, target, firstHit, lastHit);
this.applyHeldItemFlinchCheck(user, target, dealsDamage);
this.applySuccessfulAttackEffects(user, target, firstHit, lastHit, !!isProtected, hitResult, firstTarget);
} else {
applyMoveAttrs(NoEffectAttr, user, null, move);
}
if (moveHistoryEntry.result === MoveResult.PENDING) {
moveHistoryEntry.result = MoveResult.MISS;
}
user.pushMoveHistory(moveHistoryEntry);
applyMoveAttrs(MissEffectAttr, user, null, move);
continue;
}
// Apply queued phases
if (queuedPhases.length) {
globalScene.appendToPhase(queuedPhases, MoveEndPhase);
}
// Apply the move's POST_TARGET effects on the move's last hit, after all targeted effects have resolved
if (user.turnData.hitsLeft === 1 || !this.getFirstTarget()?.isActive()) {
applyFilteredMoveAttrs(
(attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_TARGET,
user,
null,
move,
);
/** Does this phase represent the invoked move's first strike? */
const firstHit = user.turnData.hitsLeft === user.turnData.hitCount;
// Only log the move's result on the first strike
if (firstHit) {
user.pushMoveHistory(moveHistoryEntry);
}
/**
* Remove the target's substitute (if it exists and has expired)
* after all targeted effects have applied.
* This prevents blocked effects from applying until after this hit resolves.
* Since all fail/miss checks have applied, the move is considered successfully applied.
* It's worth noting that if the move has no effect or is protected against, this assignment
* is overwritten and the move is logged as a FAIL.
*/
targets.forEach(target => {
const substitute = target.getTag(SubstituteTag);
if (substitute && substitute.hp <= 0) {
target.lapseTag(BattlerTagType.SUBSTITUTE);
}
});
moveHistoryEntry.result = MoveResult.SUCCESS;
const moveType = user.getMoveType(move, true);
if (move.category !== MoveCategory.STATUS && !user.stellarTypesBoosted.includes(moveType)) {
user.stellarTypesBoosted.push(moveType);
/**
* Stores the result of applying the invoked move to the target.
* If the target is protected, the result is always `NO_EFFECT`.
* Otherwise, the hit result is based on type effectiveness, immunities,
* and other factors that may negate the attack or status application.
*
* Internally, the call to {@linkcode Pokemon.apply} is where damage is calculated
* (for attack moves) and the target's HP is updated. However, this isn't
* made visible to the user until the resulting {@linkcode DamagePhase}
* is invoked.
*/
const hitResult = !isProtected ? target.apply(user, move) : HitResult.NO_EFFECT;
/** Does {@linkcode hitResult} indicate that damage was dealt to the target? */
const dealsDamage = [
HitResult.EFFECTIVE,
HitResult.SUPER_EFFECTIVE,
HitResult.NOT_VERY_EFFECTIVE,
HitResult.ONE_HIT_KO,
].includes(hitResult);
/** Is this target the first one hit by the move on its current strike? */
const firstTarget = dealsDamage && !hasHit;
if (firstTarget) {
hasHit = true;
}
this.end();
},
);
/**
* If the move has no effect on the target (i.e. the target is protected or immune),
* change the logged move result to FAIL.
*/
if (hitResult === HitResult.NO_EFFECT) {
moveHistoryEntry.result = MoveResult.FAIL;
}
/** Does this phase represent the invoked move's last strike? */
const lastHit = user.turnData.hitsLeft === 1 || !this.getFirstTarget()?.isActive();
/**
* If the user can change forms by using the invoked move,
* it only changes forms after the move's last hit
* (see Relic Song's interaction with Parental Bond when used by Meloetta).
*/
if (lastHit) {
globalScene.triggerPokemonFormChange(user, SpeciesFormChangePostMoveTrigger);
/**
* Multi-Lens, Multi Hit move and Parental Bond check for PostDamageAbAttr
* other damage source are calculated in damageAndUpdate in pokemon.ts
*/
if (user.turnData.hitCount > 1) {
applyPostDamageAbAttrs(PostDamageAbAttr, target, 0, target.hasPassive(), false, [], user);
}
}
applyFilteredMoveAttrs(
(attr: MoveAttr) =>
attr instanceof MoveEffectAttr &&
attr.trigger === MoveEffectTrigger.PRE_APPLY &&
(!attr.firstHitOnly || firstHit) &&
(!attr.lastHitOnly || lastHit) &&
hitResult !== HitResult.NO_EFFECT,
user,
target,
move,
);
if (hitResult !== HitResult.FAIL) {
this.applySelfTargetEffects(user, target, firstHit, lastHit);
if (hitResult !== HitResult.NO_EFFECT) {
this.applyPostApplyEffects(user, target, firstHit, lastHit);
this.applyHeldItemFlinchCheck(user, target, dealsDamage);
this.applySuccessfulAttackEffects(user, target, firstHit, lastHit, !!isProtected, hitResult, firstTarget);
} else {
applyMoveAttrs(NoEffectAttr, user, null, move);
}
}
}
// Apply queued phases
if (queuedPhases.length) {
globalScene.appendToPhase(queuedPhases, MoveEndPhase);
}
// Apply the move's POST_TARGET effects on the move's last hit, after all targeted effects have resolved
if (user.turnData.hitsLeft === 1 || !this.getFirstTarget()?.isActive()) {
applyFilteredMoveAttrs(
(attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_TARGET,
user,
null,
move,
);
}
/**
* Remove the target's substitute (if it exists and has expired)
* after all targeted effects have applied.
* This prevents blocked effects from applying until after this hit resolves.
*/
targets.forEach(target => {
const substitute = target.getTag(SubstituteTag);
if (substitute && substitute.hp <= 0) {
target.lapseTag(BattlerTagType.SUBSTITUTE);
}
});
const moveType = user.getMoveType(move, true);
if (move.category !== MoveCategory.STATUS && !user.stellarTypesBoosted.includes(moveType)) {
user.stellarTypesBoosted.push(moveType);
}
this.end();
});
}
public override end(): void {

View File

@ -15,6 +15,7 @@ import type { DelayedAttackTag } from "#app/data/arena-tag";
import { CommonAnim } from "#app/data/battle-anims";
import { BattlerTagLapseType, CenterOfAttentionTag } from "#app/data/battler-tags";
import {
AddArenaTrapTagAttr,
allMoves,
applyMoveAttrs,
BypassRedirectAttr,
@ -201,7 +202,10 @@ export class MovePhase extends BattlePhase {
const targets = this.getActiveTargetPokemon();
const moveQueue = this.pokemon.getMoveQueue();
if (targets.length === 0 || (moveQueue.length && moveQueue[0].move === Moves.NONE)) {
if (
(targets.length === 0 && !this.move.getMove().hasAttr(AddArenaTrapTagAttr)) ||
(moveQueue.length && moveQueue[0].move === Moves.NONE)
) {
this.showMoveText();
this.showFailedText();
this.cancel();

View File

@ -4,6 +4,7 @@ import { Species } from "#enums/species";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { ArenaTagSide, ArenaTrapTag } from "#app/data/arena-tag";
describe("Moves - Spikes", () => {
let phaserGame: Phaser.Game;
@ -77,4 +78,17 @@ describe("Moves - Spikes", () => {
const enemy = game.scene.getEnemyParty()[0];
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
}, 20000);
it("should work when all targets fainted", async () => {
game.override.enemySpecies(Species.DIGLETT);
game.override.battleType("double");
game.override.startingLevel(50);
await game.classicMode.startBattle([Species.RAYQUAZA, Species.ROWLET]);
game.move.select(Moves.EARTHQUAKE);
game.move.select(Moves.SPIKES, 1);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.arena.getTagOnSide(ArenaTrapTag, ArenaTagSide.ENEMY)).toBeDefined();
}, 20000);
});