This commit is contained in:
Sirz Benjie 2025-09-22 21:44:46 -04:00 committed by GitHub
commit 055e40e384
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1747 additions and 677 deletions

View File

@ -40,11 +40,9 @@ export type Mutable<T> = {
* @typeParam O - The type of the object * @typeParam O - The type of the object
* @typeParam V - The type of one of O's values. * @typeParam V - The type of one of O's values.
*/ */
export type InferKeys<O extends object, V> = V extends ObjectValues<O> export type InferKeys<O extends object, V> = {
? {
[K in keyof O]: O[K] extends V ? K : never; [K in keyof O]: O[K] extends V ? K : never;
}[keyof O] }[keyof O];
: never;
/** /**
* Utility type to obtain a union of the values of a given object. \ * Utility type to obtain a union of the values of a given object. \

View File

@ -48,7 +48,7 @@ import { getStatusEffectHealText } from "#data/status-effect";
import { TerrainType } from "#data/terrain"; import { TerrainType } from "#data/terrain";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import type { BattlerIndex } from "#enums/battler-index"; import type { BattlerIndex } from "#enums/battler-index";
import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type"; import { BattlerTagLapseType, type NonCustomBattlerTagLapseType } from "#enums/battler-tag-lapse-type";
import { BattlerTagType } from "#enums/battler-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type";
import { HitResult } from "#enums/hit-result"; import { HitResult } from "#enums/hit-result";
import { ChargeAnim, CommonAnim } from "#enums/move-anims-common"; import { ChargeAnim, CommonAnim } from "#enums/move-anims-common";
@ -121,6 +121,11 @@ export class BattlerTag implements BaseBattlerTag {
} }
#lapseTypes: readonly [BattlerTagLapseType, ...BattlerTagLapseType[]]; #lapseTypes: readonly [BattlerTagLapseType, ...BattlerTagLapseType[]];
/**
* The set of lapse types that this tag can be automatically lapsed with.
* If this is exclusively {@linkcode BattlerTagLapseType.CUSTOM}, then the tag can only ever be lapsed
* manually via {@linkcode Pokemon.lapseTag} (or calling the tag's lapse method directly)
*/
public get lapseTypes(): readonly BattlerTagLapseType[] { public get lapseTypes(): readonly BattlerTagLapseType[] {
return this.#lapseTypes; return this.#lapseTypes;
} }
@ -128,7 +133,7 @@ export class BattlerTag implements BaseBattlerTag {
constructor( constructor(
tagType: BattlerTagType, tagType: BattlerTagType,
lapseType: BattlerTagLapseType | [BattlerTagLapseType, ...BattlerTagLapseType[]], lapseType: BattlerTagLapseType | [NonCustomBattlerTagLapseType, ...NonCustomBattlerTagLapseType[]],
turnCount: number, turnCount: number,
sourceMove?: MoveId, sourceMove?: MoveId,
sourceId?: number, sourceId?: number,
@ -160,7 +165,10 @@ export class BattlerTag implements BaseBattlerTag {
onOverlap(_pokemon: Pokemon): void {} onOverlap(_pokemon: Pokemon): void {}
/** /**
* Tick down this {@linkcode BattlerTag}'s duration. * Apply the battler tag's effects based on the lapse type
*
* @remarks
* Generally, this involves ticking down the tag's duration. The tag also initiates the effects it is responsbile for
* @param _pokemon - The {@linkcode Pokemon} whom this tag belongs to. * @param _pokemon - The {@linkcode Pokemon} whom this tag belongs to.
* Unused by default but can be used by subclasses. * Unused by default but can be used by subclasses.
* @param _lapseType - The {@linkcode BattlerTagLapseType} being lapsed. * @param _lapseType - The {@linkcode BattlerTagLapseType} being lapsed.
@ -303,12 +311,7 @@ export abstract class MoveRestrictionBattlerTag extends SerializableBattlerTag {
export class ThroatChoppedTag extends MoveRestrictionBattlerTag { export class ThroatChoppedTag extends MoveRestrictionBattlerTag {
public override readonly tagType = BattlerTagType.THROAT_CHOPPED; public override readonly tagType = BattlerTagType.THROAT_CHOPPED;
constructor() { constructor() {
super( super(BattlerTagType.THROAT_CHOPPED, BattlerTagLapseType.TURN_END, 2, MoveId.THROAT_CHOP);
BattlerTagType.THROAT_CHOPPED,
[BattlerTagLapseType.TURN_END, BattlerTagLapseType.PRE_MOVE],
2,
MoveId.THROAT_CHOP,
);
} }
/** /**
@ -357,13 +360,7 @@ export class DisabledTag extends MoveRestrictionBattlerTag {
public readonly moveId: MoveId = MoveId.NONE; public readonly moveId: MoveId = MoveId.NONE;
constructor(sourceId: number) { constructor(sourceId: number) {
super( super(BattlerTagType.DISABLED, BattlerTagLapseType.TURN_END, 4, MoveId.DISABLE, sourceId);
BattlerTagType.DISABLED,
[BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END],
4,
MoveId.DISABLE,
sourceId,
);
} }
override isMoveRestricted(move: MoveId): boolean { override isMoveRestricted(move: MoveId): boolean {
@ -512,7 +509,10 @@ export class RechargingTag extends SerializableBattlerTag {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
}), }),
); );
(globalScene.phaseManager.getCurrentPhase() as MovePhase).cancel(); const currentPhase = globalScene.phaseManager.getCurrentPhase();
if (currentPhase.is("MovePhase")) {
currentPhase.cancel();
}
pokemon.getMoveQueue().shift(); pokemon.getMoveQueue().shift();
} }
return super.lapse(pokemon, lapseType); return super.lapse(pokemon, lapseType);
@ -699,18 +699,21 @@ class NoRetreatTag extends TrappedTag {
export class FlinchedTag extends BattlerTag { export class FlinchedTag extends BattlerTag {
public override readonly tagType = BattlerTagType.FLINCHED; public override readonly tagType = BattlerTagType.FLINCHED;
constructor(sourceMove: MoveId) { constructor(sourceMove: MoveId) {
super(BattlerTagType.FLINCHED, [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END], 1, sourceMove); super(BattlerTagType.FLINCHED, BattlerTagLapseType.TURN_END, 1, sourceMove);
} }
/** /**
* Cancels the flinched Pokemon's currently used move this turn if called mid-execution, or removes the tag at end of turn. * Cancels the flinched Pokemon's currently used move this turn if called mid-execution, or removes the tag at end of turn.
* @param pokemon - The {@linkcode Pokemon} with this tag. * @param pokemon - The {@linkcode Pokemon} with this tag.
* @param lapseType - The {@linkcode BattlerTagLapseType | lapse type} used for this function call. * @param lapseType - The {@linkcode BattlerTagLapseType | lapse type} used for this function call. Must be {@linkcode BattlerTagLapseType.PRE_MOVE} in order to apply the flinch effect.
* @returns Whether the tag should remain active. * @returns Whether the tag should remain active.
*/ */
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
if (lapseType === BattlerTagLapseType.PRE_MOVE) { if (lapseType === BattlerTagLapseType.PRE_MOVE) {
(globalScene.phaseManager.getCurrentPhase() as MovePhase).cancel(); const currentPhase = globalScene.phaseManager.getCurrentPhase();
if (currentPhase.is("MovePhase")) {
currentPhase.cancel();
}
globalScene.phaseManager.queueMessage( globalScene.phaseManager.queueMessage(
i18next.t("battlerTags:flinchedLapse", { i18next.t("battlerTags:flinchedLapse", {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
@ -720,7 +723,7 @@ export class FlinchedTag extends BattlerTag {
return true; return true;
} }
return super.lapse(pokemon, lapseType); return lapseType === BattlerTagLapseType.TURN_END && super.lapse(pokemon, lapseType);
} }
getDescriptor(): string { getDescriptor(): string {
@ -751,7 +754,10 @@ export class InterruptedTag extends BattlerTag {
} }
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
(globalScene.phaseManager.getCurrentPhase() as MovePhase).cancel(); const currentPhase = globalScene.phaseManager.getCurrentPhase();
if (currentPhase.is("MovePhase")) {
currentPhase.cancel();
}
return super.lapse(pokemon, lapseType); return super.lapse(pokemon, lapseType);
} }
} }
@ -762,7 +768,7 @@ export class InterruptedTag extends BattlerTag {
export class ConfusedTag extends SerializableBattlerTag { export class ConfusedTag extends SerializableBattlerTag {
public override readonly tagType = BattlerTagType.CONFUSED; public override readonly tagType = BattlerTagType.CONFUSED;
constructor(turnCount: number, sourceMove: MoveId) { constructor(turnCount: number, sourceMove: MoveId) {
super(BattlerTagType.CONFUSED, BattlerTagLapseType.MOVE, turnCount, sourceMove, undefined, true); super(BattlerTagType.CONFUSED, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, undefined, true);
} }
canAdd(pokemon: Pokemon): boolean { canAdd(pokemon: Pokemon): boolean {
@ -805,8 +811,19 @@ export class ConfusedTag extends SerializableBattlerTag {
); );
} }
/**
* Tick down the confusion duration and, if there are remaining turns, activate the confusion effect
*
* @remarks
* Handles playing the confusion animation, displaying the message(s), rolling for self-damage, and cancelling the
* move phase if the user hurts itself.
* @param pokemon - The pokemon with this tag
* @param lapseType - The lapse type
* @returns `true` if the tag should remain active (i.e. `turnCount` > 0)
*/
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
const shouldLapse = lapseType !== BattlerTagLapseType.CUSTOM && super.lapse(pokemon, lapseType); // Duration is only ticked down for PRE_MOVE lapse type
const shouldLapse = lapseType === BattlerTagLapseType.PRE_MOVE && super.lapse(pokemon, lapseType);
if (!shouldLapse) { if (!shouldLapse) {
return false; return false;
@ -831,7 +848,10 @@ export class ConfusedTag extends SerializableBattlerTag {
// Intentionally don't increment rage fist's hitCount // Intentionally don't increment rage fist's hitCount
phaseManager.queueMessage(i18next.t("battlerTags:confusedLapseHurtItself")); phaseManager.queueMessage(i18next.t("battlerTags:confusedLapseHurtItself"));
pokemon.damageAndUpdate(damage, { result: HitResult.CONFUSION }); pokemon.damageAndUpdate(damage, { result: HitResult.CONFUSION });
(phaseManager.getCurrentPhase() as MovePhase).cancel(); const currentPhase = phaseManager.getCurrentPhase();
if (currentPhase.is("MovePhase") && currentPhase.pokemon === pokemon) {
currentPhase.cancel();
}
} }
return true; return true;
@ -907,7 +927,7 @@ export class DestinyBondTag extends SerializableBattlerTag {
export class InfatuatedTag extends SerializableBattlerTag { export class InfatuatedTag extends SerializableBattlerTag {
public override readonly tagType = BattlerTagType.INFATUATED; public override readonly tagType = BattlerTagType.INFATUATED;
constructor(sourceMove: number, sourceId: number) { constructor(sourceMove: number, sourceId: number) {
super(BattlerTagType.INFATUATED, BattlerTagLapseType.MOVE, 1, sourceMove, sourceId); super(BattlerTagType.INFATUATED, BattlerTagLapseType.CUSTOM, 1, sourceMove, sourceId);
} }
canAdd(pokemon: Pokemon): boolean { canAdd(pokemon: Pokemon): boolean {
@ -970,7 +990,10 @@ export class InfatuatedTag extends SerializableBattlerTag {
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
}), }),
); );
(phaseManager.getCurrentPhase() as MovePhase).cancel(); const currentPhase = phaseManager.getCurrentPhase();
if (currentPhase.is("MovePhase")) {
currentPhase.cancel();
}
} }
return true; return true;
@ -1095,7 +1118,7 @@ export class SeedTag extends SerializableBattlerTag {
export class PowderTag extends BattlerTag { export class PowderTag extends BattlerTag {
public override readonly tagType = BattlerTagType.POWDER; public override readonly tagType = BattlerTagType.POWDER;
constructor() { constructor() {
super(BattlerTagType.POWDER, [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END], 1); super(BattlerTagType.POWDER, BattlerTagLapseType.TURN_END, 1);
} }
onAdd(pokemon: Pokemon): void { onAdd(pokemon: Pokemon): void {
@ -1117,23 +1140,26 @@ export class PowderTag extends BattlerTag {
* @returns `true` if the tag should remain active. * @returns `true` if the tag should remain active.
*/ */
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
const movePhase = globalScene.phaseManager.getCurrentPhase(); if (lapseType === BattlerTagLapseType.TURN_END) {
if (lapseType !== BattlerTagLapseType.PRE_MOVE || !movePhase?.is("MovePhase")) {
return false; return false;
} }
const currentPhase = globalScene.phaseManager.getCurrentPhase();
const move = movePhase.move.getMove(); if (!currentPhase.is("MovePhase")) {
return true;
}
const move = currentPhase.move.getMove();
const weather = globalScene.arena.weather; const weather = globalScene.arena.weather;
if ( if (
pokemon.getMoveType(move) !== PokemonType.FIRE pokemon.getMoveType(move) !== PokemonType.FIRE
|| (weather?.weatherType === WeatherType.HEAVY_RAIN && !weather.isEffectSuppressed()) // Heavy rain takes priority over powder || (weather?.weatherType === WeatherType.HEAVY_RAIN && !weather.isEffectSuppressed()) // Since gen 7, Heavy rain takes priority over powder
) { ) {
return true; return true;
} }
// Disable the target's fire type move and damage it (subject to Magic Guard) // Disable the target's fire type move and damage it (subject to Magic Guard)
movePhase.showMoveText(); currentPhase.fail();
movePhase.fail();
const idx = pokemon.getBattlerIndex(); const idx = pokemon.getBattlerIndex();
@ -1233,13 +1259,7 @@ export class EncoreTag extends MoveRestrictionBattlerTag {
public moveId: MoveId; public moveId: MoveId;
constructor(sourceId: number) { constructor(sourceId: number) {
super( super(BattlerTagType.ENCORE, BattlerTagLapseType.AFTER_MOVE, 3, MoveId.ENCORE, sourceId);
BattlerTagType.ENCORE,
[BattlerTagLapseType.CUSTOM, BattlerTagLapseType.AFTER_MOVE],
3,
MoveId.ENCORE,
sourceId,
);
} }
public override loadTag(source: BaseBattlerTag & Pick<EncoreTag, "tagType" | "moveId">): void { public override loadTag(source: BaseBattlerTag & Pick<EncoreTag, "tagType" | "moveId">): void {
@ -1737,7 +1757,7 @@ export class ProtectedTag extends BattlerTag {
// Stop multi-hit moves early // Stop multi-hit moves early
const effectPhase = globalScene.phaseManager.getCurrentPhase(); const effectPhase = globalScene.phaseManager.getCurrentPhase();
if (effectPhase?.is("MoveEffectPhase")) { if (effectPhase.is("MoveEffectPhase")) {
effectPhase.stopMultiHit(pokemon); effectPhase.stopMultiHit(pokemon);
} }
return true; return true;
@ -2021,7 +2041,7 @@ export class UnburdenTag extends AbilityBattlerTag {
export class TruantTag extends AbilityBattlerTag { export class TruantTag extends AbilityBattlerTag {
public override readonly tagType = BattlerTagType.TRUANT; public override readonly tagType = BattlerTagType.TRUANT;
constructor() { constructor() {
super(BattlerTagType.TRUANT, AbilityId.TRUANT, BattlerTagLapseType.MOVE, 1); super(BattlerTagType.TRUANT, AbilityId.TRUANT, BattlerTagLapseType.CUSTOM, 1);
} }
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
@ -2751,7 +2771,7 @@ export class GulpMissileTag extends SerializableBattlerTag {
} }
const moveEffectPhase = globalScene.phaseManager.getCurrentPhase(); const moveEffectPhase = globalScene.phaseManager.getCurrentPhase();
if (moveEffectPhase?.is("MoveEffectPhase")) { if (moveEffectPhase.is("MoveEffectPhase")) {
const attacker = moveEffectPhase.getUserPokemon(); const attacker = moveEffectPhase.getUserPokemon();
if (!attacker) { if (!attacker) {
@ -2848,12 +2868,7 @@ export class ExposedTag extends SerializableBattlerTag {
export class HealBlockTag extends MoveRestrictionBattlerTag { export class HealBlockTag extends MoveRestrictionBattlerTag {
public override readonly tagType = BattlerTagType.HEAL_BLOCK; public override readonly tagType = BattlerTagType.HEAL_BLOCK;
constructor(turnCount: number, sourceMove: MoveId) { constructor(turnCount: number, sourceMove: MoveId) {
super( super(BattlerTagType.HEAL_BLOCK, BattlerTagLapseType.TURN_END, turnCount, sourceMove);
BattlerTagType.HEAL_BLOCK,
[BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END],
turnCount,
sourceMove,
);
} }
onActivation(pokemon: Pokemon): string { onActivation(pokemon: Pokemon): string {
@ -3044,7 +3059,7 @@ export class SubstituteTag extends SerializableBattlerTag {
constructor(sourceMove: MoveId, sourceId: number) { constructor(sourceMove: MoveId, sourceId: number) {
super( super(
BattlerTagType.SUBSTITUTE, BattlerTagType.SUBSTITUTE,
[BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.AFTER_MOVE, BattlerTagLapseType.HIT], [BattlerTagLapseType.MOVE, BattlerTagLapseType.AFTER_MOVE, BattlerTagLapseType.HIT],
0, 0,
sourceMove, sourceMove,
sourceId, sourceId,
@ -3102,7 +3117,7 @@ export class SubstituteTag extends SerializableBattlerTag {
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
switch (lapseType) { switch (lapseType) {
case BattlerTagLapseType.PRE_MOVE: case BattlerTagLapseType.MOVE:
this.onPreMove(pokemon); this.onPreMove(pokemon);
break; break;
case BattlerTagLapseType.AFTER_MOVE: case BattlerTagLapseType.AFTER_MOVE:
@ -3130,7 +3145,7 @@ export class SubstituteTag extends SerializableBattlerTag {
/** If the Substitute redirects damage, queue a message to indicate it. */ /** If the Substitute redirects damage, queue a message to indicate it. */
onHit(pokemon: Pokemon): void { onHit(pokemon: Pokemon): void {
const moveEffectPhase = globalScene.phaseManager.getCurrentPhase(); const moveEffectPhase = globalScene.phaseManager.getCurrentPhase();
if (moveEffectPhase?.is("MoveEffectPhase")) { if (moveEffectPhase.is("MoveEffectPhase")) {
const attacker = moveEffectPhase.getUserPokemon(); const attacker = moveEffectPhase.getUserPokemon();
if (!attacker) { if (!attacker) {
return; return;
@ -3260,7 +3275,7 @@ export class TormentTag extends MoveRestrictionBattlerTag {
export class TauntTag extends MoveRestrictionBattlerTag { export class TauntTag extends MoveRestrictionBattlerTag {
public override readonly tagType = BattlerTagType.TAUNT; public override readonly tagType = BattlerTagType.TAUNT;
constructor() { constructor() {
super(BattlerTagType.TAUNT, [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.AFTER_MOVE], 4, MoveId.TAUNT); super(BattlerTagType.TAUNT, BattlerTagLapseType.AFTER_MOVE, 4, MoveId.TAUNT);
} }
override onAdd(pokemon: Pokemon) { override onAdd(pokemon: Pokemon) {
@ -3315,13 +3330,7 @@ export class TauntTag extends MoveRestrictionBattlerTag {
export class ImprisonTag extends MoveRestrictionBattlerTag { export class ImprisonTag extends MoveRestrictionBattlerTag {
public override readonly tagType = BattlerTagType.IMPRISON; public override readonly tagType = BattlerTagType.IMPRISON;
constructor(sourceId: number) { constructor(sourceId: number) {
super( super(BattlerTagType.IMPRISON, BattlerTagLapseType.AFTER_MOVE, 1, MoveId.IMPRISON, sourceId);
BattlerTagType.IMPRISON,
[BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.AFTER_MOVE],
1,
MoveId.IMPRISON,
sourceId,
);
} }
/** /**
@ -3514,7 +3523,7 @@ export class PowerTrickTag extends SerializableBattlerTag {
export class GrudgeTag extends SerializableBattlerTag { export class GrudgeTag extends SerializableBattlerTag {
public override readonly tagType = BattlerTagType.GRUDGE; public override readonly tagType = BattlerTagType.GRUDGE;
constructor() { constructor() {
super(BattlerTagType.GRUDGE, [BattlerTagLapseType.CUSTOM, BattlerTagLapseType.PRE_MOVE], 1, MoveId.GRUDGE); super(BattlerTagType.GRUDGE, BattlerTagLapseType.PRE_MOVE, 1, MoveId.GRUDGE);
} }
onAdd(pokemon: Pokemon) { onAdd(pokemon: Pokemon) {
@ -3535,7 +3544,9 @@ export class GrudgeTag extends SerializableBattlerTag {
*/ */
// TODO: Confirm whether this should interact with copying moves // TODO: Confirm whether this should interact with copying moves
override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType, sourcePokemon?: Pokemon): boolean { override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType, sourcePokemon?: Pokemon): boolean {
if (lapseType === BattlerTagLapseType.CUSTOM && sourcePokemon) { if (!sourcePokemon || lapseType !== BattlerTagLapseType.CUSTOM) {
return super.lapse(pokemon, lapseType);
}
if (sourcePokemon.isActive() && pokemon.isOpponent(sourcePokemon)) { if (sourcePokemon.isActive() && pokemon.isOpponent(sourcePokemon)) {
const lastMove = pokemon.turnData.attacksReceived[0]; const lastMove = pokemon.turnData.attacksReceived[0];
const lastMoveData = sourcePokemon.getMoveset().find(m => m.moveId === lastMove.move); const lastMoveData = sourcePokemon.getMoveset().find(m => m.moveId === lastMove.move);
@ -3551,8 +3562,6 @@ export class GrudgeTag extends SerializableBattlerTag {
} }
return false; return false;
} }
return super.lapse(pokemon, lapseType);
}
} }
/** /**
@ -3780,8 +3789,6 @@ export function getBattlerTag(
case BattlerTagType.ALWAYS_GET_HIT: case BattlerTagType.ALWAYS_GET_HIT:
case BattlerTagType.RECEIVE_DOUBLE_DAMAGE: case BattlerTagType.RECEIVE_DOUBLE_DAMAGE:
return new SerializableBattlerTag(tagType, BattlerTagLapseType.PRE_MOVE, 1, sourceMove); return new SerializableBattlerTag(tagType, BattlerTagLapseType.PRE_MOVE, 1, sourceMove);
case BattlerTagType.BYPASS_SLEEP:
return new BattlerTag(tagType, BattlerTagLapseType.TURN_END, turnCount, sourceMove);
case BattlerTagType.IGNORE_FLYING: case BattlerTagType.IGNORE_FLYING:
return new GroundedTag(tagType, BattlerTagLapseType.CUSTOM, sourceMove); return new GroundedTag(tagType, BattlerTagLapseType.CUSTOM, sourceMove);
case BattlerTagType.ROOSTED: case BattlerTagType.ROOSTED:
@ -3886,7 +3893,7 @@ export function loadBattlerTag(source: BattlerTag | BattlerTagData): BattlerTag
*/ */
function getMoveEffectPhaseData(_pokemon: Pokemon): { phase: MoveEffectPhase; attacker: Pokemon; move: Move } | null { function getMoveEffectPhaseData(_pokemon: Pokemon): { phase: MoveEffectPhase; attacker: Pokemon; move: Move } | null {
const phase = globalScene.phaseManager.getCurrentPhase(); const phase = globalScene.phaseManager.getCurrentPhase();
if (phase?.is("MoveEffectPhase")) { if (phase.is("MoveEffectPhase")) {
return { return {
phase, phase,
attacker: phase.getPokemon(), attacker: phase.getPokemon(),
@ -3955,7 +3962,6 @@ export type BattlerTagTypeMap = {
[BattlerTagType.IGNORE_ACCURACY]: GenericSerializableBattlerTag<BattlerTagType.IGNORE_ACCURACY>; [BattlerTagType.IGNORE_ACCURACY]: GenericSerializableBattlerTag<BattlerTagType.IGNORE_ACCURACY>;
[BattlerTagType.ALWAYS_GET_HIT]: GenericSerializableBattlerTag<BattlerTagType.ALWAYS_GET_HIT>; [BattlerTagType.ALWAYS_GET_HIT]: GenericSerializableBattlerTag<BattlerTagType.ALWAYS_GET_HIT>;
[BattlerTagType.RECEIVE_DOUBLE_DAMAGE]: GenericSerializableBattlerTag<BattlerTagType.RECEIVE_DOUBLE_DAMAGE>; [BattlerTagType.RECEIVE_DOUBLE_DAMAGE]: GenericSerializableBattlerTag<BattlerTagType.RECEIVE_DOUBLE_DAMAGE>;
[BattlerTagType.BYPASS_SLEEP]: BattlerTag;
[BattlerTagType.IGNORE_FLYING]: GroundedTag; [BattlerTagType.IGNORE_FLYING]: GroundedTag;
[BattlerTagType.ROOSTED]: RoostedTag; [BattlerTagType.ROOSTED]: RoostedTag;
[BattlerTagType.BURNED_UP]: RemovedTypeTag; [BattlerTagType.BURNED_UP]: RemovedTypeTag;

View File

@ -0,0 +1,264 @@
// biome-ignore lint/correctness/noUnusedImports: Used in a TSDoc comment
import type { GameMode } from "#app/game-mode";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import { TrappedTag } from "#data/battler-tags";
import { allMoves } from "#data/data-lists";
import { ArenaTagType } from "#enums/arena-tag-type";
import { Command } from "#enums/command";
import { MoveCategory, type MoveDamageCategory } from "#enums/move-category";
import type { MoveId } from "#enums/move-id";
import { isVirtual } from "#enums/move-use-mode";
import { PokemonType } from "#enums/pokemon-type";
import type { Pokemon } from "#field/pokemon";
import type { Move, MoveConditionFunc, UserMoveConditionFunc } from "#moves/move";
import { getCounterAttackTarget } from "#moves/move-utils";
import i18next from "i18next";
/**
* A condition that determines whether a move can be used successfully.
*
* @remarks
* This is only checked when the move is attempted to be invoked. To prevent a move from being selected,
* use a {@linkcode MoveRestriction} instead.
*/
export class MoveCondition {
public declare readonly func: MoveConditionFunc;
/**
* @param func - A condition function that determines if the move can be used successfully
*/
constructor(func: MoveConditionFunc) {
this.func = func;
}
apply(user: Pokemon, target: Pokemon, move: Move): boolean {
return this.func(user, target, move);
}
getUserBenefitScore(_user: Pokemon, _target: Pokemon, _move: Move): number {
return 0;
}
}
/**
* Condition to allow a move's use only on the first turn this Pokemon is sent into battle
* (or the start of a new wave, whichever comes first).
*/
export class FirstMoveCondition extends MoveCondition {
constructor() {
super(user => user.tempSummonData.waveTurnCount === 1);
}
// TODO: Update AI move selection logic to not require this method at all
// Currently, it is used to avoid having the AI select the move if its condition will fail
getUserBenefitScore(user: Pokemon, _target: Pokemon, _move: Move): number {
return this.apply(user, _target, _move) ? 10 : -20;
}
}
/**
* Condition that fails the move if the user has less than 1/x of their max HP.
* @remarks
* Used by Clangorous Soul and Fillet Away
*
* NOT used by Belly Drum, whose failure check occurs in phase 4 along with its stat increase condition
*/
export class FailIfInsufficientHpCondition extends MoveCondition {
/**
* Condition that fails the move if the user has less than 1/x of their max HP.
* @param ratio - The required HP ratio (the `x` in `1/x`)
*/
constructor(cutRatio: number) {
super(user => user.getHpRatio() > 1 / cutRatio);
}
}
/**
* Teleport condition checks
*
* @remarks
* For trainer pokemon, just checks if there are any benched pokemon allowed in battle
*
* Wild pokemon cannot teleport if either:
* - The current battle is a double battle
* - They are under the effects of a *move-based* trapping effect like and are neither a ghost type nor have an active run away ability
*/
export const failTeleportCondition = new MoveCondition(user => {
if (user.hasTrainer()) {
const party = user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty();
for (const pokemon of party) {
if (!pokemon.isOnField() && pokemon.isAllowedInBattle()) {
return true;
}
}
return false;
}
// Wild pokemon
// Cannot teleport in double battles (even if last remaining)
if (globalScene.currentBattle.double) {
return false;
}
// If smoke ball / shed tail items are ever added, checks for them should be placed here
// If a conditional "run away" ability is ever added, then we should use the apply method instead of the `hasAbility`
if (user.isOfType(PokemonType.GHOST, true, true) || user.hasAbilityWithAttr("RunSuccessAbAttr")) {
return true;
}
// Wild pokemon are prevented from fleeing if they are trapped *specifically*
if (globalScene.arena.hasTag(ArenaTagType.FAIRY_LOCK) || user.getTag(TrappedTag) !== undefined) {
// Fairy Lock prevents teleporting
return false;
}
return true;
});
/**
* Condition that forces moves to fail if the target's selected move is not an attacking move
*
* @remarks
* Used by Sucker Punch and Thunderclap
*/
export const failIfTargetNotAttackingCondition = new MoveCondition((_user, target) => {
const turnCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()];
if (!turnCommand || !turnCommand.move) {
return false;
}
return (
turnCommand.command === Command.FIGHT
&& !target.turnData.acted
&& allMoves[turnCommand.move.move].category !== MoveCategory.STATUS
);
});
/**
* Condition that forces moves to fail against the final boss in classic and the major boss in endless
* @remarks
* Only works reliably for single-target moves as only one target is provided; should not be used for multi-target moves
* @see {@linkcode GameMode.isBattleClassicFinalBoss}
* @see {@linkcode GameMode.isEndlessMinorBoss}
*/
export const failAgainstFinalBossCondition = new MoveCondition((_user, target) => {
const gameMode = globalScene.gameMode;
const currentWave = globalScene.currentBattle.waveIndex;
return !(
target.isEnemy()
&& (gameMode.isBattleClassicFinalBoss(currentWave) || gameMode.isEndlessMinorBoss(currentWave))
);
});
/**
* Condition used by the move {@link https://bulbapedia.bulbagarden.net/wiki/Upper_Hand_(move) | Upper Hand}.
* Moves with this condition are only successful when the target has selected
* a high-priority attack (after factoring in priority-boosting effects) and
* hasn't moved yet this turn.
*/
export const upperHandCondition = new MoveCondition((_user, target) => {
const targetCommand = globalScene.currentBattle.turnCommands[target.getBattlerIndex()];
return (
targetCommand?.command === Command.FIGHT
&& !target.turnData.acted
&& !!targetCommand.move?.move
&& allMoves[targetCommand.move.move].category !== MoveCategory.STATUS
&& allMoves[targetCommand.move.move].getPriority(target) > 0
);
});
/**
* Condition used by the move {@link https://bulbapedia.bulbagarden.net/wiki/Last_Resort_(move) | Last Resort}
*
* @remarks
* Last resort fails if
* - It is not in the user's moveset
* - The user does not know at least one other move
* - The user has not directly used each other move in its moveset since it was sent into battle
* - A move is considered *used* for this purpose if it passed the first failure check sequence in the move phase
* (i.e. its usage message was displayed)
*/
export const lastResortCondition = new MoveCondition((user, _target, move) => {
const otherMovesInMoveset = new Set<MoveId>(user.getMoveset().map(m => m.moveId));
if (!otherMovesInMoveset.delete(move.id) || otherMovesInMoveset.size === 0) {
return false; // Last resort fails if used when not in user's moveset or no other moves exist
}
const movesInHistory = new Set<MoveId>(
user
.getMoveHistory()
.filter(m => !isVirtual(m.useMode)) // Last resort ignores virtual moves
.map(m => m.move),
);
// Since `Set.intersection()` is only present in ESNext, we have to do this to check inclusion
return [...otherMovesInMoveset].every(m => movesInHistory.has(m));
});
/**
* Condition used by counter-like moves if the user was hit by at least one qualifying attack this turn.
* Qualifying attacks are those that match the specified category (physical, special or either)
* that did not come from an ally.
*/
class CounterAttackConditon extends MoveCondition {
/**
* @param damageCategory - The category of move to counter (physical or special), or `undefined` to counter both
*/
constructor(damageCategory?: MoveDamageCategory) {
super(user => getCounterAttackTarget(user, damageCategory) !== null);
}
}
/** Condition check for counterattacks that proc againt physical moves */
export const counterAttackConditionPhysical = new CounterAttackConditon(MoveCategory.PHYSICAL);
/** Condition check for counterattacks that proc against special moves*/
export const counterAttackConditionSpecial = new CounterAttackConditon(MoveCategory.SPECIAL);
/** Condition check for counterattacks that proc against moves regardless of damage type */
export const counterAttackConditionBoth = new CounterAttackConditon();
/**
* A restriction that prevents a move from being selected
*
* @remarks
* Only checked when the move is selected, but not when it is attempted to be invoked. To prevent a move from being used,
* use a {@linkcode MoveCondition} instead.
*/
export class MoveRestriction {
public declare readonly func: UserMoveConditionFunc;
public declare readonly i18nkey: string;
constructor(func: UserMoveConditionFunc, i18nkey = "battle:moveRestricted") {
this.func = func;
this.i18nkey = i18nkey;
}
/**
* @param user - The Pokemon attempting to select the move
* @param move - The move being selected
* @returns Whether the move is restricted for the user.
*/
apply(user: Pokemon, move: Move): boolean {
return this.func(user, move);
}
public getSelectionDeniedText(user: Pokemon, move: Move): string {
// While not all restriction texts use all the parameters, passing extra ones is harmless
return i18next.t(this.i18nkey, { pokemonNameWithAffix: getPokemonNameWithAffix(user), moveName: move.name });
}
}
/**
* Prevents a Pokemon from using the move if it was the last move it used
*
* @remarks
* Used by {@link https://bulbapedia.bulbagarden.net/wiki/Blood_Moon_(move) | Blood Moon} and {@link https://bulbapedia.bulbagarden.net/wiki/Gigaton_Hammer_(move) | Gigaton Hammer}
*/
export const consecutiveUseRestriction = new MoveRestriction(
(user, move) => user.getLastXMoves(1)[0]?.move === move.id,
"battle:moveDisabledConsecutive",
);
/** Prevents a move from being selected if Gravity is in effect */
export const gravityUseRestriction = new MoveRestriction(
() => globalScene.arena.hasTag(ArenaTagType.GRAVITY),
"battle:moveDisabledGravity",
);

View File

@ -1,6 +1,7 @@
import { allMoves } from "#data/data-lists"; import { allMoves } from "#data/data-lists";
import type { BattlerIndex } from "#enums/battler-index"; import type { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveCategory, type MoveDamageCategory } from "#enums/move-category";
import type { MoveId } from "#enums/move-id"; import type { MoveId } from "#enums/move-id";
import { MoveTarget } from "#enums/move-target"; import { MoveTarget } from "#enums/move-target";
import { PokemonType } from "#enums/pokemon-type"; import { PokemonType } from "#enums/pokemon-type";
@ -8,6 +9,7 @@ import type { Pokemon } from "#field/pokemon";
import { applyMoveAttrs } from "#moves/apply-attrs"; import { applyMoveAttrs } from "#moves/apply-attrs";
import type { Move, MoveTargetSet, UserMoveConditionFunc } from "#moves/move"; import type { Move, MoveTargetSet, UserMoveConditionFunc } from "#moves/move";
import { NumberHolder } from "#utils/common"; import { NumberHolder } from "#utils/common";
import { areAllies } from "#utils/pokemon-utils";
/** /**
* Return whether the move targets the field * Return whether the move targets the field
@ -133,3 +135,25 @@ export const frenzyMissFunc: UserMoveConditionFunc = (user: Pokemon, move: Move)
return true; return true;
}; };
/**
* Determine the target for the `user`'s counter-attack move
* @param user - The pokemon using the counter-like move
* @param damageCategory - The category of move to counter (physical or special), or `undefined` to counter both
* @returns - The battler index of the most recent, non-ally attacker using a move that matches the specified category, or `null` if no such attacker exists
*/
export function getCounterAttackTarget(user: Pokemon, damageCategory?: MoveDamageCategory): BattlerIndex | null {
for (const attackRecord of user.turnData.attacksReceived) {
// check if the attacker was an ally
const moveCategory = allMoves[attackRecord.move].category;
const sourceBattlerIndex = attackRecord.sourceBattlerIndex;
if (
moveCategory !== MoveCategory.STATUS
&& !areAllies(sourceBattlerIndex, user.getBattlerIndex())
&& (damageCategory === undefined || moveCategory === damageCategory)
) {
return sourceBattlerIndex;
}
}
return null;
}

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@ import type { Pokemon } from "#field/pokemon";
import type { Move } from "#moves/move"; import type { Move } from "#moves/move";
import { applyChallenges } from "#utils/challenge-utils"; import { applyChallenges } from "#utils/challenge-utils";
import { BooleanHolder, toDmgValue } from "#utils/common"; import { BooleanHolder, toDmgValue } from "#utils/common";
import i18next from "i18next";
/** /**
* Wrapper class for the {@linkcode Move} class for Pokemon to interact with. * Wrapper class for the {@linkcode Move} class for Pokemon to interact with.
@ -38,32 +39,59 @@ export class PokemonMove {
} }
/** /**
* Checks whether this move can be selected/performed by a Pokemon, without consideration for the move's targets. * Checks whether this move can be performed by a Pokemon, without consideration for the move's targets.
* The move is unusable if it is out of PP, restricted by an effect, or unimplemented. * The move is unusable if it is out of PP, restricted by an effect, or unimplemented.
* *
* Should not be confused with {@linkcode isSelectable}, which only checks if the move can be selected by a Pokemon.
*
* @param pokemon - The {@linkcode Pokemon} attempting to use this move * @param pokemon - The {@linkcode Pokemon} attempting to use this move
* @param ignorePp - Whether to ignore checking if the move is out of PP; default `false` * @param ignorePp - Whether to ignore checking if the move is out of PP; default `false`
* @param ignoreRestrictionTags - Whether to skip checks for {@linkcode MoveRestrictionBattlerTag}s; default `false` * @param forSelection - Whether this is being checked for move selection; default `false`
* @returns Whether this {@linkcode PokemonMove} can be selected by this Pokemon. * @returns A tuple containing a boolean indicating whether the move can be selected, and a string with the reason if it cannot
*/ */
isUsable(pokemon: Pokemon, ignorePp = false, ignoreRestrictionTags = false): boolean { public isUsable(pokemon: Pokemon, ignorePp = false, forSelection = false): [usable: boolean, preventionText: string] {
const move = this.getMove(); const move = this.getMove();
const moveName = move.name;
// TODO: Add Sky Drop's 1 turn stall // TODO: Add Sky Drop's 1 turn stall
const usability = new BooleanHolder( if (this.moveId === MoveId.NONE || move.name.endsWith(" (N)")) {
!move.name.endsWith(" (N)") return [false, i18next.t("battle:moveNotImplemented", moveName.replace(" (N)", ""))];
&& (ignorePp || this.ppUsed < this.getMovePp() || move.pp === -1) // TODO: Review if the `MoveId.NONE` check is even necessary anymore
&& !(this.moveId !== MoveId.NONE && !ignoreRestrictionTags && pokemon.isMoveRestricted(this.moveId, pokemon)),
);
if (pokemon.isPlayer()) {
applyChallenges(ChallengeType.POKEMON_MOVE, move.id, usability);
} }
return usability.value;
if (!ignorePp && move.pp !== -1 && this.ppUsed >= this.getMovePp()) {
return [false, i18next.t("battle:moveNoPP", { moveName: move.name })];
}
if (forSelection) {
const result = pokemon.isMoveSelectable(this.moveId);
if (!result[0]) {
return result;
}
}
const usability = new BooleanHolder(true);
if (pokemon.isPlayer() && applyChallenges(ChallengeType.POKEMON_MOVE, this.moveId, usability) && !usability.value) {
return [false, i18next.t("battle:moveCannotUseChallenge", { moveName: move.name })];
}
return [true, ""];
} }
getMove(): Move { getMove(): Move {
return allMoves[this.moveId]; return allMoves[this.moveId];
} }
/**
* Determine whether the move can be selected by the pokemon based on its own requirements
* @remarks
* Does not check for PP, moves blocked by challenges, or unimplemented moves, all of which are handled by {@linkcode isUsable}
* @param pokemon - The Pokemon under consideration
* @returns An tuple containing a boolean indicating whether the move can be selected, and a string with the reason if it cannot
*/
public isSelectable(pokemon: Pokemon): [selectable: boolean, preventionText: string] {
return pokemon.isMoveSelectable(this.moveId);
}
/** /**
* Sets {@link ppUsed} for this move and ensures the value does not exceed {@link getMovePp} * Sets {@link ppUsed} for this move and ensures the value does not exceed {@link getMovePp}
* @param count Amount of PP to use * @param count Amount of PP to use

View File

@ -1,3 +1,5 @@
import type { MoveUseMode } from "#enums/move-use-mode";
/** /**
* Enum representing the possible ways a given BattlerTag can activate and/or tick down. * Enum representing the possible ways a given BattlerTag can activate and/or tick down.
* Each tag can have multiple different behaviors attached to different lapse types. * Each tag can have multiple different behaviors attached to different lapse types.
@ -6,11 +8,23 @@ export enum BattlerTagLapseType {
// TODO: This is unused... // TODO: This is unused...
FAINT, FAINT,
/** /**
* Tag activate before the holder uses a non-virtual move, possibly interrupting its action. * Tag activates before the holder uses a non-virtual move, after passing the first failure check sequence during the
* move phase.
* @see MoveUseMode for more information * @see MoveUseMode for more information
*/ */
MOVE, MOVE,
/** Tag activates before the holder uses **any** move, triggering effects or interrupting its action. */ /**
* Tag activates during (or just after) the first failure check sequence in the move phase
*
* @remarks
*
* Note tags with this lapse type will lapse immediately after the first failure check sequence,
* regardless of whether the move was successful or not, but is skipped if the move is a
* {@linkcode MoveUseMode.FOLLOW_UP | follow-up} move.
*
* To only lapse the tag between the first and second failure check sequences, use
* {@linkcode BattlerTagLapseType.MOVE} instead.
*/
PRE_MOVE, PRE_MOVE,
/** Tag activates immediately after the holder's move finishes triggering (successful or not). */ /** Tag activates immediately after the holder's move finishes triggering (successful or not). */
AFTER_MOVE, AFTER_MOVE,
@ -32,6 +46,16 @@ export enum BattlerTagLapseType {
* but still triggers on being KO'd. * but still triggers on being KO'd.
*/ */
AFTER_HIT, AFTER_HIT,
/** The tag has some other custom activation or removal condition. */ /**
* The tag has some other custom activation or removal condition.
* @remarks
* Tags can use this lapse type to prevent them from automatically lapsing during automatic lapse instances,
* such as before a move is used or at the end of a turn.
* Such tags will only trigger upon being specifically lapsed with the tag and lapse type via
* {@linkcode Pokemon.lapseTag}.
*/
CUSTOM, CUSTOM,
} }
/** Same type as {@linkcode BattlerTagLapseType}, but excludes the {@linkcode BattlerTagLapseType.CUSTOM} type */
export type NonCustomBattlerTagLapseType = Exclude<BattlerTagLapseType, BattlerTagLapseType.CUSTOM>;

View File

@ -47,7 +47,6 @@ export enum BattlerTagType {
CRIT_BOOST = "CRIT_BOOST", CRIT_BOOST = "CRIT_BOOST",
ALWAYS_CRIT = "ALWAYS_CRIT", ALWAYS_CRIT = "ALWAYS_CRIT",
IGNORE_ACCURACY = "IGNORE_ACCURACY", IGNORE_ACCURACY = "IGNORE_ACCURACY",
BYPASS_SLEEP = "BYPASS_SLEEP",
IGNORE_FLYING = "IGNORE_FLYING", IGNORE_FLYING = "IGNORE_FLYING",
SALT_CURED = "SALT_CURED", SALT_CURED = "SALT_CURED",
CURSED = "CURSED", CURSED = "CURSED",

View File

@ -3,3 +3,6 @@ export enum MoveCategory {
SPECIAL, SPECIAL,
STATUS, STATUS,
} }
/** Type of damage categories */
export type MoveDamageCategory = Exclude<MoveCategory, MoveCategory.STATUS>;

View File

@ -47,8 +47,8 @@ export enum MoveFlags {
CHECK_ALL_HITS = 1 << 16, CHECK_ALL_HITS = 1 << 16,
/** Indicates a move is able to bypass its target's Substitute (if the target has one) */ /** Indicates a move is able to bypass its target's Substitute (if the target has one) */
IGNORE_SUBSTITUTE = 1 << 17, IGNORE_SUBSTITUTE = 1 << 17,
/** Indicates a move is able to be redirected to allies in a double battle if the attacker faints */
REDIRECT_COUNTER = 1 << 18,
/** Indicates a move is able to be reflected by {@linkcode AbilityId.MAGIC_BOUNCE} and {@linkcode MoveId.MAGIC_COAT} */ /** Indicates a move is able to be reflected by {@linkcode AbilityId.MAGIC_BOUNCE} and {@linkcode MoveId.MAGIC_COAT} */
REFLECTABLE = 1 << 19, REFLECTABLE = 1 << 18,
/** Indicates a move should fail when {@link https://bulbapedia.bulbagarden.net/wiki/Gravity_(move) | Gravity} is in effect */
GRAVITY = 1 << 19,
} }

View File

@ -2410,8 +2410,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* modifiers from move and ability attributes * modifiers from move and ability attributes
* @param source - The attacking Pokémon. * @param source - The attacking Pokémon.
* @param move - The move being used by the attacking Pokémon. * @param move - The move being used by the attacking Pokémon.
* @param ignoreAbility - Whether to ignore abilities that might affect type effectiveness or immunity (defaults to `false`). * @param ignoreAbility - Whether to ignore abilities that might affect type effectiveness or immunity; default `false`
* @param simulated - Whether to apply abilities via simulated calls (defaults to `true`) * @param simulated - Whether to apply abilities via simulated calls; default `true`; Should only ever be false during `moveEffect` phase
* @param cancelled - Stores whether the move was cancelled by a non-type-based immunity. * @param cancelled - Stores whether the move was cancelled by a non-type-based immunity.
* @param useIllusion - Whether to consider an active illusion * @param useIllusion - Whether to consider an active illusion
* @returns The type damage multiplier, indicating the effectiveness of the move * @returns The type damage multiplier, indicating the effectiveness of the move
@ -2465,7 +2465,9 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
applyAbAttrs("MoveImmunityAbAttr", commonAbAttrParams); applyAbAttrs("MoveImmunityAbAttr", commonAbAttrParams);
} }
if (!cancelledHolder.value) { // Do not check queenly majesty unless this is being simulated
// This is because the move effect phase should not check queenly majesty, as that is handled by the move phase
if (simulated && !cancelledHolder.value) {
const defendingSidePlayField = this.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField(); const defendingSidePlayField = this.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField();
defendingSidePlayField.forEach((p: (typeof defendingSidePlayField)[0]) => { defendingSidePlayField.forEach((p: (typeof defendingSidePlayField)[0]) => {
applyAbAttrs("FieldPriorityMoveImmunityAbAttr", { applyAbAttrs("FieldPriorityMoveImmunityAbAttr", {
@ -3131,9 +3133,15 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
} }
} }
public trySelectMove(moveIndex: number, ignorePp?: boolean): boolean { /**
* Attempt to select the move at the move index.
* @param moveIndex - The index of the move to select
* @param ignorePp - Whether to ignore PP when checking if the move is usable (defaults to false)
* @returns A tuple containing a boolean indicating if the move can be selected, and a string with the reason if it cannot be selected
*/
public trySelectMove(moveIndex: number, ignorePp?: boolean): [isUsable: boolean, failureMessage: string] {
const move = this.getMoveset().length > moveIndex ? this.getMoveset()[moveIndex] : null; const move = this.getMoveset().length > moveIndex ? this.getMoveset()[moveIndex] : null;
return move?.isUsable(this, ignorePp) ?? false; return move?.isUsable(this, ignorePp, true) ?? [false, ""];
} }
/** Show this Pokémon's info panel */ /** Show this Pokémon's info panel */
@ -3220,7 +3228,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
/** /**
* Check whether the specified Pokémon is an opponent * Check whether the specified Pokémon is an opponent
* @param target - The {@linkcode Pokemon} to compare against * @param target - The {@linkcode Pokemon} to compare against
* @returns `true` if the two pokemon are allies, `false` otherwise * @returns `true` if the two pokemon are opponents, `false` otherwise
*/ */
public isOpponent(target: Pokemon): boolean { public isOpponent(target: Pokemon): boolean {
return this.isPlayer() !== target.isPlayer(); return this.isPlayer() !== target.isPlayer();
@ -4112,19 +4120,28 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
} }
/** /**
* Tick down the first {@linkcode BattlerTag} found matching the given {@linkcode BattlerTagType}, * Lapse the first {@linkcode BattlerTag} matching `tagType`
* removing it if its duration goes below 0. *
* @param tagType - The `BattlerTagType` to lapse * @remarks
* @returns Whether the tag was present * Also responsible for removing the tag when the lapse method returns `false`.
*
*
* Lapse types other than `CUSTOM` are generally lapsed automatically. However, some tags
* support manually lapsing
*
* @param tagType - The {@linkcode BattlerTagType} to search for
* @param lapseType - The lapse type to use for the lapse method; defaults to {@linkcode BattlerTagLapseType.CUSTOM}
* @returns Whether a tag matching the given type was found
* @see {@linkcode BattlerTag.lapse}
*/ */
public lapseTag(tagType: BattlerTagType): boolean { public lapseTag(tagType: BattlerTagType, lapseType = BattlerTagLapseType.CUSTOM): boolean {
const tags = this.summonData.tags; const tags = this.summonData.tags;
const tag = tags.find(t => t.tagType === tagType); const tag = tags.find(t => t.tagType === tagType);
if (!tag) { if (!tag) {
return false; return false;
} }
if (!tag.lapse(this, BattlerTagLapseType.CUSTOM)) { if (!tag.lapse(this, lapseType)) {
tag.onRemove(this); tag.onRemove(this);
tags.splice(tags.indexOf(tag), 1); tags.splice(tags.indexOf(tag), 1);
} }
@ -4136,7 +4153,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* `lapseType`, removing any whose durations fall below 0. * `lapseType`, removing any whose durations fall below 0.
* @param lapseType - The type of lapse to process * @param lapseType - The type of lapse to process
*/ */
public lapseTags(lapseType: BattlerTagLapseType): void { public lapseTags(lapseType: Exclude<BattlerTagLapseType, BattlerTagLapseType.CUSTOM>): void {
const tags = this.summonData.tags; const tags = this.summonData.tags;
tags tags
.filter( .filter(
@ -4248,13 +4265,37 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
/** /**
* Get whether the given move is currently disabled for this Pokémon * Get whether the given move is currently disabled for this Pokémon
* *
* @remarks
* Only checks for restrictions due to a battler tag, not due to the move's own attributes.
* (for that behavior, see {@linkcode isMoveSelectable}).
*
* @param moveId - The ID of the move to check * @param moveId - The ID of the move to check
* @returns `true` if the move is disabled for this Pokemon, otherwise `false` * @returns `true` if the move is disabled for this Pokemon, otherwise `false`
* *
* @see {@linkcode MoveRestrictionBattlerTag} * @see {@linkcode MoveRestrictionBattlerTag}
*/ */
public isMoveRestricted(moveId: MoveId, pokemon?: Pokemon): boolean { // TODO: rename this method as it can be easily confused with a move being restricted
return this.getRestrictingTag(moveId, pokemon) !== null; public isMoveRestricted(moveId: MoveId): boolean {
return this.getRestrictingTag(moveId, this) !== null;
}
/**
* Determine whether the given move is selectable by this Pokemon and the message to display if it is not.
*
* @remarks
* Checks both the move's own restrictions and any restrictions imposed by battler tags like disable or throat chop.
*
* @param moveId - The move ID to check
* @returns A tuple of the form [response, msg], where msg contains the text to display if `response` is false.
*
* @see {@linkcode isMoveRestricted}
*/
public isMoveSelectable(moveId: MoveId): [boolean, string] {
const restrictedTag = this.getRestrictingTag(moveId, this);
if (restrictedTag) {
return [false, restrictedTag.selectionDeniedText(this, moveId)];
}
return allMoves[moveId].checkRestrictions(this);
} }
/** /**
@ -6415,7 +6456,7 @@ export class EnemyPokemon extends Pokemon {
// If the queued move was called indirectly, ignore all PP and usability checks. // If the queued move was called indirectly, ignore all PP and usability checks.
// Otherwise, ensure that the move being used is actually usable & in our moveset. // Otherwise, ensure that the move being used is actually usable & in our moveset.
// TODO: What should happen if a pokemon forgets a charging move mid-use? // TODO: What should happen if a pokemon forgets a charging move mid-use?
if (isVirtual(queuedMove.useMode) || movesetMove?.isUsable(this, isIgnorePP(queuedMove.useMode))) { if (isVirtual(queuedMove.useMode) || movesetMove?.isUsable(this, isIgnorePP(queuedMove.useMode), true)) {
moveQueue.splice(0, i); // TODO: This should not be done here moveQueue.splice(0, i); // TODO: This should not be done here
return queuedMove; return queuedMove;
} }
@ -6425,7 +6466,7 @@ export class EnemyPokemon extends Pokemon {
this.summonData.moveQueue = []; this.summonData.moveQueue = [];
// Filter out any moves this Pokemon cannot use // Filter out any moves this Pokemon cannot use
let movePool = this.getMoveset().filter(m => m.isUsable(this)); let movePool = this.getMoveset().filter(m => m.isUsable(this, false, true)[0]);
// If no moves are left, use Struggle. Otherwise, continue with move selection // If no moves are left, use Struggle. Otherwise, continue with move selection
if (movePool.length > 0) { if (movePool.length > 0) {
// If there's only 1 move in the move pool, use it. // If there's only 1 move in the move pool, use it.
@ -6483,7 +6524,7 @@ export class EnemyPokemon extends Pokemon {
move.category !== MoveCategory.STATUS move.category !== MoveCategory.STATUS
&& moveTargets.some(p => { && moveTargets.some(p => {
const doesNotFail = const doesNotFail =
move.applyConditions(this, p, move) move.applyConditions(this, p, -1)
|| [MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id); || [MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id);
return ( return (
doesNotFail doesNotFail
@ -6544,7 +6585,7 @@ export class EnemyPokemon extends Pokemon {
* target score to -20 * target score to -20
*/ */
if ( if (
(move.name.endsWith(" (N)") || !move.applyConditions(this, target, move)) (move.name.endsWith(" (N)") || !move.applyConditions(this, target, -1))
&& ![MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id) && ![MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id)
) { ) {
targetScore = -20; targetScore = -20;
@ -6882,6 +6923,10 @@ export class EnemyPokemon extends Pokemon {
} }
public getBattlerIndex(): BattlerIndex { public getBattlerIndex(): BattlerIndex {
const fieldIndex = this.getFieldIndex();
if (fieldIndex === -1) {
return BattlerIndex.ATTACKER;
}
return BattlerIndex.ENEMY + this.getFieldIndex(); return BattlerIndex.ENEMY + this.getFieldIndex();
} }

View File

@ -9,7 +9,6 @@ import { ArenaTagType } from "#enums/arena-tag-type";
import { BattleType } from "#enums/battle-type"; import { BattleType } from "#enums/battle-type";
import { BattlerTagType } from "#enums/battler-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type";
import { BiomeId } from "#enums/biome-id"; import { BiomeId } from "#enums/biome-id";
import { ChallengeType } from "#enums/challenge-type";
import { Command } from "#enums/command"; import { Command } from "#enums/command";
import { FieldPosition } from "#enums/field-position"; import { FieldPosition } from "#enums/field-position";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
@ -20,11 +19,8 @@ import { UiMode } from "#enums/ui-mode";
import type { PlayerPokemon } from "#field/pokemon"; import type { PlayerPokemon } from "#field/pokemon";
import type { MoveTargetSet } from "#moves/move"; import type { MoveTargetSet } from "#moves/move";
import { getMoveTargets } from "#moves/move-utils"; import { getMoveTargets } from "#moves/move-utils";
import type { PokemonMove } from "#moves/pokemon-move";
import { FieldPhase } from "#phases/field-phase"; import { FieldPhase } from "#phases/field-phase";
import type { TurnMove } from "#types/turn-move"; import type { TurnMove } from "#types/turn-move";
import { applyChallenges } from "#utils/challenge-utils";
import { BooleanHolder } from "#utils/common";
import i18next from "i18next"; import i18next from "i18next";
export class CommandPhase extends FieldPhase { export class CommandPhase extends FieldPhase {
@ -127,7 +123,7 @@ export class CommandPhase extends FieldPhase {
if ( if (
queuedMove.move !== MoveId.NONE queuedMove.move !== MoveId.NONE
&& !isVirtual(queuedMove.useMode) && !isVirtual(queuedMove.useMode)
&& !movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode)) && !movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode), true)
) { ) {
entriesToDelete++; entriesToDelete++;
} else { } else {
@ -205,39 +201,18 @@ export class CommandPhase extends FieldPhase {
} }
/** /**
* Submethod of {@linkcode handleFightCommand} responsible for queuing the appropriate * Submethod of {@linkcode handleFightCommand} responsible for queuing the provided error message when the move cannot be used
* error message when a move cannot be used. * @param msg - The reason why the move cannot be used
* @param user - The pokemon using the move
* @param move - The move that cannot be used
*/ */
private queueFightErrorMessage(user: PlayerPokemon, move: PokemonMove) { private queueFightErrorMessage(msg: string) {
globalScene.ui.setMode(UiMode.MESSAGE); const ui = globalScene.ui;
ui.setMode(UiMode.MESSAGE);
// Set the translation key for why the move cannot be selected ui.showText(
let cannotSelectKey: string; msg,
const moveStatus = new BooleanHolder(true);
applyChallenges(ChallengeType.POKEMON_MOVE, move.moveId, moveStatus);
if (!moveStatus.value) {
cannotSelectKey = "battle:moveCannotUseChallenge";
} else if (move.getPpRatio() === 0) {
cannotSelectKey = "battle:moveNoPp";
} else if (move.getName().endsWith(" (N)")) {
cannotSelectKey = "battle:moveNotImplemented";
} else if (user.isMoveRestricted(move.moveId, user)) {
cannotSelectKey = user.getRestrictingTag(move.moveId, user)!.selectionDeniedText(user, move.moveId);
} else {
// TODO: Consider a message that signals a being unusable for an unknown reason
cannotSelectKey = "";
}
const moveName = move.getName().replace(" (N)", ""); // Trims off the indicator
globalScene.ui.showText(
i18next.t(cannotSelectKey, { moveName }),
null, null,
() => { () => {
globalScene.ui.clearText(); ui.clearText();
globalScene.ui.setMode(UiMode.FIGHT, this.fieldIndex); ui.setMode(UiMode.FIGHT, this.fieldIndex);
}, },
null, null,
true, true,
@ -274,22 +249,16 @@ export class CommandPhase extends FieldPhase {
): boolean { ): boolean {
const playerPokemon = this.getPokemon(); const playerPokemon = this.getPokemon();
const ignorePP = isIgnorePP(useMode); const ignorePP = isIgnorePP(useMode);
const [canUse, reason] = cursor === -1 ? [true, ""] : playerPokemon.trySelectMove(cursor, ignorePP);
let canUse = cursor === -1 || playerPokemon.trySelectMove(cursor, ignorePP);
const moveset = playerPokemon.getMoveset();
// Ternary here ensures we don't compute struggle conditions unless necessary // Ternary here ensures we don't compute struggle conditions unless necessary
const useStruggle = canUse ? false : cursor > -1 && !moveset.some(m => m.isUsable(playerPokemon)); const useStruggle = canUse
? false
: cursor > -1 && !playerPokemon.getMoveset().some(m => m.isUsable(playerPokemon, ignorePP, true)[0]);
canUse ||= useStruggle; if (!canUse && !useStruggle) {
console.error("Cannot use move:", reason);
if (!canUse) { this.queueFightErrorMessage(reason);
// Selected move *may* be undefined if the cursor is over a position that the mon does not have
const selectedMove: PokemonMove | undefined = moveset[cursor];
if (selectedMove) {
this.queueFightErrorMessage(playerPokemon, moveset[cursor]);
}
return false; return false;
} }

View File

@ -20,7 +20,7 @@ export class MoveHeaderPhase extends BattlePhase {
} }
canMove(): boolean { canMove(): boolean {
return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon); return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon)[0];
} }
start() { start() {

File diff suppressed because it is too large Load Diff

View File

@ -74,7 +74,7 @@ export class CommandUiHandler extends UiHandler {
let commandPhase: CommandPhase; let commandPhase: CommandPhase;
const currentPhase = globalScene.phaseManager.getCurrentPhase(); const currentPhase = globalScene.phaseManager.getCurrentPhase();
if (currentPhase?.is("CommandPhase")) { if (currentPhase.is("CommandPhase")) {
commandPhase = currentPhase; commandPhase = currentPhase;
} else { } else {
commandPhase = globalScene.phaseManager.getStandbyPhase() as CommandPhase; commandPhase = globalScene.phaseManager.getStandbyPhase() as CommandPhase;

View File

@ -1,7 +1,12 @@
// biome-ignore-start lint/correctness/noUnusedImports: Used in a TSDoc comment
import type { Pokemon } from "#field/pokemon";
// biome-ignore-end lint/correctness/noUnusedImports: Used in a TSDoc comment
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { POKERUS_STARTER_COUNT, speciesStarterCosts } from "#balance/starters"; import { POKERUS_STARTER_COUNT, speciesStarterCosts } from "#balance/starters";
import { allSpecies } from "#data/data-lists"; import { allSpecies } from "#data/data-lists";
import type { PokemonSpecies, PokemonSpeciesForm } from "#data/pokemon-species"; import type { PokemonSpecies, PokemonSpeciesForm } from "#data/pokemon-species";
import { BattlerIndex } from "#enums/battler-index";
import type { SpeciesId } from "#enums/species-id"; import type { SpeciesId } from "#enums/species-id";
import { randSeedItem } from "./common"; import { randSeedItem } from "./common";
@ -123,3 +128,20 @@ export function getPokemonSpeciesForm(species: SpeciesId, formIndex: number): Po
} }
return retSpecies; return retSpecies;
} }
/**
* Return whether two battler indices are considered allies.
* To instead check with {@linkcode Pokemon} objects, use {@linkcode Pokemon.isOpponent}.
* @param a - First battler index
* @param b - Second battler index
* @returns Whether the two battler indices are allies. Always `false` if either index is `ATTACKER`.
*/
export function areAllies(a: BattlerIndex, b: BattlerIndex): boolean {
if (a === BattlerIndex.ATTACKER || b === BattlerIndex.ATTACKER) {
return false;
}
return (
(a === BattlerIndex.PLAYER || a === BattlerIndex.PLAYER_2)
=== (b === BattlerIndex.PLAYER || b === BattlerIndex.PLAYER_2)
);
}

View File

@ -1,4 +1,3 @@
import { CudChewConsumeBerryAbAttr } from "#abilities/ability";
import { globalScene } from "#app/global-scene"; import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages"; import { getPokemonNameWithAffix } from "#app/messages";
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
@ -6,7 +5,6 @@ import { BerryType } from "#enums/berry-type";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat"; import { Stat } from "#enums/stat";
import { Pokemon } from "#field/pokemon";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import i18next from "i18next"; import i18next from "i18next";
import Phaser from "phaser"; import Phaser from "phaser";
@ -111,7 +109,6 @@ describe("Abilities - Cud Chew", () => {
it("can store multiple berries across 2 turns with teatime", async () => { it("can store multiple berries across 2 turns with teatime", async () => {
// always eat first berry for stuff cheeks & company // always eat first berry for stuff cheeks & company
vi.spyOn(Pokemon.prototype, "randBattleSeedInt").mockReturnValue(0);
game.override game.override
.startingHeldItems([ .startingHeldItems([
{ name: "BERRY", type: BerryType.PETAYA, count: 3 }, { name: "BERRY", type: BerryType.PETAYA, count: 3 },
@ -121,7 +118,10 @@ describe("Abilities - Cud Chew", () => {
await game.classicMode.startBattle([SpeciesId.FARIGIRAF]); await game.classicMode.startBattle([SpeciesId.FARIGIRAF]);
const farigiraf = game.field.getPlayerPokemon(); const farigiraf = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
farigiraf.hp = 1; // needed to allow berry procs farigiraf.hp = 1; // needed to allow berry procs
vi.spyOn(farigiraf, "randBattleSeedInt").mockReturnValue(0);
vi.spyOn(enemy, "randBattleSeedInt").mockReturnValue(0);
game.move.select(MoveId.STUFF_CHEEKS); game.move.select(MoveId.STUFF_CHEEKS);
await game.toNextTurn(); await game.toNextTurn();
@ -196,10 +196,10 @@ describe("Abilities - Cud Chew", () => {
describe("regurgiates berries", () => { describe("regurgiates berries", () => {
it("re-triggers effects on eater without pushing to array", async () => { it("re-triggers effects on eater without pushing to array", async () => {
const apply = vi.spyOn(CudChewConsumeBerryAbAttr.prototype, "apply");
await game.classicMode.startBattle([SpeciesId.FARIGIRAF]); await game.classicMode.startBattle([SpeciesId.FARIGIRAF]);
const farigiraf = game.field.getPlayerPokemon(); const farigiraf = game.field.getPlayerPokemon();
const apply = vi.spyOn(farigiraf.getAbilityAttrs("CudChewConsumeBerryAbAttr")[0], "apply");
farigiraf.hp = 1; farigiraf.hp = 1;
game.move.select(MoveId.SPLASH); game.move.select(MoveId.SPLASH);

View File

@ -142,7 +142,7 @@ describe("BattlerTag - SubstituteTag", () => {
vi.spyOn(messages, "getPokemonNameWithAffix").mockReturnValue(""); vi.spyOn(messages, "getPokemonNameWithAffix").mockReturnValue("");
}); });
it("PRE_MOVE lapse triggers pre-move animation", async () => { it("MOVE lapse triggers pre-move animation", async () => {
const subject = new SubstituteTag(MoveId.SUBSTITUTE, mockPokemon.id); const subject = new SubstituteTag(MoveId.SUBSTITUTE, mockPokemon.id);
vi.spyOn(mockPokemon.scene as BattleScene, "triggerPokemonBattleAnim").mockImplementation( vi.spyOn(mockPokemon.scene as BattleScene, "triggerPokemonBattleAnim").mockImplementation(
@ -154,7 +154,7 @@ describe("BattlerTag - SubstituteTag", () => {
vi.spyOn((mockPokemon.scene as BattleScene).phaseManager, "queueMessage").mockReturnValue(); vi.spyOn((mockPokemon.scene as BattleScene).phaseManager, "queueMessage").mockReturnValue();
expect(subject.lapse(mockPokemon, BattlerTagLapseType.PRE_MOVE)).toBeTruthy(); expect(subject.lapse(mockPokemon, BattlerTagLapseType.MOVE)).toBeTruthy();
expect(subject.sourceInFocus).toBeTruthy(); expect(subject.sourceInFocus).toBeTruthy();
expect((mockPokemon.scene as BattleScene).triggerPokemonBattleAnim).toHaveBeenCalledTimes(1); expect((mockPokemon.scene as BattleScene).triggerPokemonBattleAnim).toHaveBeenCalledTimes(1);

View File

@ -43,8 +43,10 @@ describe("Challenges - Hardcore", () => {
await game.challengeMode.startBattle([SpeciesId.NUZLEAF]); await game.challengeMode.startBattle([SpeciesId.NUZLEAF]);
const player = game.field.getPlayerPokemon(); const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
const revBlessing = player.getMoveset()[0]; const revBlessing = player.getMoveset()[0];
expect(revBlessing.isUsable(player)).toBe(false); expect(revBlessing.isUsable(player)[0]).toBe(false);
expect(revBlessing.isUsable(enemy)[0]).toBe(true);
game.move.select(MoveId.REVIVAL_BLESSING); game.move.select(MoveId.REVIVAL_BLESSING);
await game.toEndOfTurn(); await game.toEndOfTurn();

49
test/moves/belch.test.ts Normal file
View File

@ -0,0 +1,49 @@
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Move - Belch", () => {
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
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.moveset(MoveId.BELCH)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.SPLASH);
});
it("should only be selectable if the user has previously eaten a berry", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const player = game.field.getPlayerPokemon();
expect(
!game.field.getPlayerPokemon().isMoveSelectable(MoveId.BELCH),
"Belch should not be selectable without a berry",
);
player.battleData.hasEatenBerry = true;
expect(
game.field.getPlayerPokemon().isMoveSelectable(MoveId.BELCH),
"Belch should be selectable after eating a berry",
);
});
});

View File

@ -35,7 +35,7 @@ describe("Moves - Copycat", () => {
.enemyMoveset(MoveId.SPLASH); .enemyMoveset(MoveId.SPLASH);
}); });
it("should copy the last move successfully executed", async () => { it("should copy the last move executed across turns", async () => {
game.override.enemyMoveset(MoveId.SUCKER_PUNCH); game.override.enemyMoveset(MoveId.SUCKER_PUNCH);
await game.classicMode.startBattle([SpeciesId.FEEBAS]); await game.classicMode.startBattle([SpeciesId.FEEBAS]);
@ -43,6 +43,7 @@ describe("Moves - Copycat", () => {
await game.toNextTurn(); await game.toNextTurn();
game.move.select(MoveId.COPYCAT); // Last successful move should be Swords Dance game.move.select(MoveId.COPYCAT); // Last successful move should be Swords Dance
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toNextTurn(); await game.toNextTurn();
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(4); expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(4);

View File

@ -90,7 +90,7 @@ describe("Moves - Dig", () => {
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
}); });
it("should not expend PP when the attack phase is cancelled", async () => { it("should expend PP when the attack phase is cancelled by sleep", async () => {
game.override.enemyAbility(AbilityId.NO_GUARD).enemyMoveset(MoveId.SPORE); game.override.enemyAbility(AbilityId.NO_GUARD).enemyMoveset(MoveId.SPORE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
@ -104,7 +104,7 @@ describe("Moves - Dig", () => {
expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP); expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP);
const playerDig = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.DIG); const playerDig = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.DIG);
expect(playerDig?.ppUsed).toBe(0); expect(playerDig?.ppUsed).toBe(1);
}); });
it("should cause the user to take double damage from Earthquake", async () => { it("should cause the user to take double damage from Earthquake", async () => {

View File

@ -74,7 +74,7 @@ describe("Moves - Dive", () => {
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS); expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
}); });
it("should not expend PP when the attack phase is cancelled", async () => { it("should expend PP when the attack phase is cancelled by sleep", async () => {
game.override.enemyAbility(AbilityId.NO_GUARD).enemyMoveset(MoveId.SPORE); game.override.enemyAbility(AbilityId.NO_GUARD).enemyMoveset(MoveId.SPORE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
@ -88,7 +88,7 @@ describe("Moves - Dive", () => {
expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP); expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP);
const playerDive = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.DIVE); const playerDive = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.DIVE);
expect(playerDive?.ppUsed).toBe(0); expect(playerDive?.ppUsed).toBe(1);
}); });
it("should trigger on-contact post-defend ability effects", async () => { it("should trigger on-contact post-defend ability effects", async () => {

View File

@ -85,6 +85,7 @@ describe("Moves - Fly", () => {
const playerPokemon = game.field.getPlayerPokemon(); const playerPokemon = game.field.getPlayerPokemon();
game.move.select(MoveId.FLY); game.move.select(MoveId.FLY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase"); await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined(); expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined();
@ -94,7 +95,7 @@ describe("Moves - Fly", () => {
expect(playerFly?.ppUsed).toBe(0); expect(playerFly?.ppUsed).toBe(0);
}); });
it("should be cancelled when another Pokemon uses Gravity", async () => { it("should be interrupted when another Pokemon uses Gravity", async () => {
game.override.enemyMoveset([MoveId.SPLASH, MoveId.GRAVITY]); game.override.enemyMoveset([MoveId.SPLASH, MoveId.GRAVITY]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
@ -115,6 +116,6 @@ describe("Moves - Fly", () => {
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp()); expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY); const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(0); expect(playerFly?.ppUsed).toBe(1);
}); });
}); });

View File

@ -33,7 +33,7 @@ describe("Moves - Gigaton Hammer", () => {
}); });
it("can't be used two turns in a row", async () => { it("can't be used two turns in a row", async () => {
await game.classicMode.startBattle(); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemy1 = game.field.getEnemyPokemon(); const enemy1 = game.field.getEnemyPokemon();
@ -46,17 +46,17 @@ describe("Moves - Gigaton Hammer", () => {
await game.doKillOpponents(); await game.doKillOpponents();
await game.toNextWave(); await game.toNextWave();
// Attempting to use Gigaton Hammer again should result in struggle
game.move.select(MoveId.GIGATON_HAMMER); game.move.select(MoveId.GIGATON_HAMMER);
await game.toNextTurn(); await game.toNextTurn();
const enemy2 = game.field.getEnemyPokemon(); const player = game.field.getPlayerPokemon();
expect(player.getLastXMoves()[0]?.move).toBe(MoveId.STRUGGLE);
expect(enemy2.hp).toBe(enemy2.getMaxHp());
}); });
it("can be used again if recalled and sent back out", async () => { it("can be used again if recalled and sent back out", async () => {
game.override.startingWave(4); game.override.startingWave(4);
await game.classicMode.startBattle(); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemy1 = game.field.getEnemyPokemon(); const enemy1 = game.field.getEnemyPokemon();

View File

@ -97,9 +97,16 @@ describe("Moves - Sleep Talk", () => {
game.move.select(MoveId.SLEEP_TALK); game.move.select(MoveId.SLEEP_TALK);
await game.toNextTurn(); await game.toNextTurn();
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK));
});
const feebas = game.field.getPlayerPokemon(); it("should apply secondary effects of a move", async () => {
expect(feebas.getStatStage(Stat.SPD)).toBe(1); game.override.moveset([MoveId.SLEEP_TALK, MoveId.DIG, MoveId.FLY, MoveId.WOOD_HAMMER]); // Dig and Fly are invalid moves, Wood Hammer should always be called
expect(feebas.getStatStage(Stat.DEF)).toBe(-1); await game.classicMode.startBattle([SpeciesId.FEEBAS]);
game.move.select(MoveId.SLEEP_TALK);
await game.toNextTurn();
expect(game.field.getPlayerPokemon().isFullHp()).toBeFalsy(); // Wood Hammer recoil effect should be applied
}); });
}); });

102
test/moves/teleport.test.ts Normal file
View File

@ -0,0 +1,102 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Move - Teleport", () => {
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
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.moveset(MoveId.SPLASH)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset([MoveId.SPLASH, MoveId.TELEPORT, MoveId.MEMENTO])
.startingLevel(100)
.enemyLevel(100);
});
describe("used by a wild pokemon", () => {
it("should fail in a double battle", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.FEEBAS]);
const [enemy1, enemy2] = game.scene.getEnemyField();
game.move.select(MoveId.SPLASH);
game.move.select(MoveId.SPLASH, 1);
await game.move.selectEnemyMove(MoveId.TELEPORT);
await game.move.selectEnemyMove(MoveId.MEMENTO, 1);
await game.toEndOfTurn();
expect(enemy1.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(enemy2.isOnField()).toBe(false);
// Fail teleport even if the other enemy faints
game.move.select(MoveId.SPLASH);
game.move.select(MoveId.SPLASH, 1);
await game.move.selectEnemyMove(MoveId.TELEPORT);
await game.toEndOfTurn();
expect(enemy1.getLastXMoves()[0].result, "should fail even if last remaining pokemon").toBe(MoveResult.FAIL);
});
it("should fail if used by a wild pokemon under a trapping effect", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.FAIRY_LOCK);
await game.move.selectEnemyMove(MoveId.TELEPORT);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(enemy.getLastXMoves()[0].result, "should fail while trapped").toBe(MoveResult.FAIL);
});
});
it("should succeed if used by a trapped wild pokemon that is ghost type", async () => {
game.override.enemySpecies(SpeciesId.GASTLY);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.FAIRY_LOCK);
await game.move.selectEnemyMove(MoveId.TELEPORT);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase", false);
expect(enemy.isOnField(), "should not be on the field").toBe(false);
});
it("should succeed if used by a trapped wild pokemon that has run away", async () => {
game.override.enemyAbility(AbilityId.RUN_AWAY);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.FAIRY_LOCK);
await game.move.selectEnemyMove(MoveId.TELEPORT);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase", false);
expect(enemy.isOnField(), "should not be on the field").toBe(false);
});
});

View File

@ -35,6 +35,7 @@ describe("Moves - Throat Chop", () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemy = game.field.getEnemyPokemon(); const enemy = game.field.getEnemyPokemon();
const player = game.field.getPlayerPokemon();
game.move.select(MoveId.GROWL); game.move.select(MoveId.GROWL);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
@ -46,6 +47,7 @@ describe("Moves - Throat Chop", () => {
// Second turn, struggle if no valid moves // Second turn, struggle if no valid moves
await game.toNextTurn(); await game.toNextTurn();
expect(player.trySelectMove(MoveId.GROWL)[0]).toBe(false);
game.move.select(MoveId.GROWL); game.move.select(MoveId.GROWL);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);

View File

@ -84,11 +84,11 @@ describe("UI - Type Hints", () => {
await game.phaseInterceptor.to("CommandPhase"); await game.phaseInterceptor.to("CommandPhase");
}); });
it("should show the proper hint for a move in doubles after one of the enemy pokemon flees", async () => { it("should show the proper hint for a move in doubles after one of the enemy pokemon faints", async () => {
game.override game.override
.enemySpecies(SpeciesId.ABRA) .enemySpecies(SpeciesId.ABRA)
.moveset([MoveId.SPLASH, MoveId.SHADOW_BALL, MoveId.SOAK]) .moveset([MoveId.SPLASH, MoveId.SHADOW_BALL, MoveId.SOAK])
.enemyMoveset([MoveId.SPLASH, MoveId.TELEPORT]) .enemyMoveset([MoveId.SPLASH, MoveId.MEMENTO])
.battleStyle("double"); .battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]); await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
@ -97,7 +97,7 @@ describe("UI - Type Hints", () => {
game.move.select(MoveId.SOAK, 1); game.move.select(MoveId.SOAK, 1);
await game.move.selectEnemyMove(MoveId.SPLASH); await game.move.selectEnemyMove(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.TELEPORT); await game.move.selectEnemyMove(MoveId.MEMENTO);
await game.toNextTurn(); await game.toNextTurn();
game.onNextPrompt("CommandPhase", UiMode.COMMAND, () => { game.onNextPrompt("CommandPhase", UiMode.COMMAND, () => {