mirror of
https://github.com/pagefaultgames/pokerogue.git
synced 2025-09-23 15:03:24 +02:00
Merge d6d2870f02
into 8d5ba221d8
This commit is contained in:
commit
055e40e384
@ -40,11 +40,9 @@ export type Mutable<T> = {
|
||||
* @typeParam O - The type of the object
|
||||
* @typeParam V - The type of one of O's values.
|
||||
*/
|
||||
export type InferKeys<O extends object, V> = V extends ObjectValues<O>
|
||||
? {
|
||||
[K in keyof O]: O[K] extends V ? K : never;
|
||||
}[keyof O]
|
||||
: never;
|
||||
export type InferKeys<O extends object, V> = {
|
||||
[K in keyof O]: O[K] extends V ? K : never;
|
||||
}[keyof O];
|
||||
|
||||
/**
|
||||
* Utility type to obtain a union of the values of a given object. \
|
||||
|
@ -48,7 +48,7 @@ import { getStatusEffectHealText } from "#data/status-effect";
|
||||
import { TerrainType } from "#data/terrain";
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
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 { HitResult } from "#enums/hit-result";
|
||||
import { ChargeAnim, CommonAnim } from "#enums/move-anims-common";
|
||||
@ -121,6 +121,11 @@ export class BattlerTag implements BaseBattlerTag {
|
||||
}
|
||||
|
||||
#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[] {
|
||||
return this.#lapseTypes;
|
||||
}
|
||||
@ -128,7 +133,7 @@ export class BattlerTag implements BaseBattlerTag {
|
||||
|
||||
constructor(
|
||||
tagType: BattlerTagType,
|
||||
lapseType: BattlerTagLapseType | [BattlerTagLapseType, ...BattlerTagLapseType[]],
|
||||
lapseType: BattlerTagLapseType | [NonCustomBattlerTagLapseType, ...NonCustomBattlerTagLapseType[]],
|
||||
turnCount: number,
|
||||
sourceMove?: MoveId,
|
||||
sourceId?: number,
|
||||
@ -160,7 +165,10 @@ export class BattlerTag implements BaseBattlerTag {
|
||||
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.
|
||||
* Unused by default but can be used by subclasses.
|
||||
* @param _lapseType - The {@linkcode BattlerTagLapseType} being lapsed.
|
||||
@ -303,12 +311,7 @@ export abstract class MoveRestrictionBattlerTag extends SerializableBattlerTag {
|
||||
export class ThroatChoppedTag extends MoveRestrictionBattlerTag {
|
||||
public override readonly tagType = BattlerTagType.THROAT_CHOPPED;
|
||||
constructor() {
|
||||
super(
|
||||
BattlerTagType.THROAT_CHOPPED,
|
||||
[BattlerTagLapseType.TURN_END, BattlerTagLapseType.PRE_MOVE],
|
||||
2,
|
||||
MoveId.THROAT_CHOP,
|
||||
);
|
||||
super(BattlerTagType.THROAT_CHOPPED, BattlerTagLapseType.TURN_END, 2, MoveId.THROAT_CHOP);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -357,13 +360,7 @@ export class DisabledTag extends MoveRestrictionBattlerTag {
|
||||
public readonly moveId: MoveId = MoveId.NONE;
|
||||
|
||||
constructor(sourceId: number) {
|
||||
super(
|
||||
BattlerTagType.DISABLED,
|
||||
[BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END],
|
||||
4,
|
||||
MoveId.DISABLE,
|
||||
sourceId,
|
||||
);
|
||||
super(BattlerTagType.DISABLED, BattlerTagLapseType.TURN_END, 4, MoveId.DISABLE, sourceId);
|
||||
}
|
||||
|
||||
override isMoveRestricted(move: MoveId): boolean {
|
||||
@ -512,7 +509,10 @@ export class RechargingTag extends SerializableBattlerTag {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
}),
|
||||
);
|
||||
(globalScene.phaseManager.getCurrentPhase() as MovePhase).cancel();
|
||||
const currentPhase = globalScene.phaseManager.getCurrentPhase();
|
||||
if (currentPhase.is("MovePhase")) {
|
||||
currentPhase.cancel();
|
||||
}
|
||||
pokemon.getMoveQueue().shift();
|
||||
}
|
||||
return super.lapse(pokemon, lapseType);
|
||||
@ -699,18 +699,21 @@ class NoRetreatTag extends TrappedTag {
|
||||
export class FlinchedTag extends BattlerTag {
|
||||
public override readonly tagType = BattlerTagType.FLINCHED;
|
||||
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.
|
||||
* @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.
|
||||
*/
|
||||
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
|
||||
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(
|
||||
i18next.t("battlerTags:flinchedLapse", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
@ -720,7 +723,7 @@ export class FlinchedTag extends BattlerTag {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.lapse(pokemon, lapseType);
|
||||
return lapseType === BattlerTagLapseType.TURN_END && super.lapse(pokemon, lapseType);
|
||||
}
|
||||
|
||||
getDescriptor(): string {
|
||||
@ -751,7 +754,10 @@ export class InterruptedTag extends BattlerTag {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -762,7 +768,7 @@ export class InterruptedTag extends BattlerTag {
|
||||
export class ConfusedTag extends SerializableBattlerTag {
|
||||
public override readonly tagType = BattlerTagType.CONFUSED;
|
||||
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 {
|
||||
@ -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 {
|
||||
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) {
|
||||
return false;
|
||||
@ -831,7 +848,10 @@ export class ConfusedTag extends SerializableBattlerTag {
|
||||
// Intentionally don't increment rage fist's hitCount
|
||||
phaseManager.queueMessage(i18next.t("battlerTags:confusedLapseHurtItself"));
|
||||
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;
|
||||
@ -907,7 +927,7 @@ export class DestinyBondTag extends SerializableBattlerTag {
|
||||
export class InfatuatedTag extends SerializableBattlerTag {
|
||||
public override readonly tagType = BattlerTagType.INFATUATED;
|
||||
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 {
|
||||
@ -970,7 +990,10 @@ export class InfatuatedTag extends SerializableBattlerTag {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
}),
|
||||
);
|
||||
(phaseManager.getCurrentPhase() as MovePhase).cancel();
|
||||
const currentPhase = phaseManager.getCurrentPhase();
|
||||
if (currentPhase.is("MovePhase")) {
|
||||
currentPhase.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -1095,7 +1118,7 @@ export class SeedTag extends SerializableBattlerTag {
|
||||
export class PowderTag extends BattlerTag {
|
||||
public override readonly tagType = BattlerTagType.POWDER;
|
||||
constructor() {
|
||||
super(BattlerTagType.POWDER, [BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END], 1);
|
||||
super(BattlerTagType.POWDER, BattlerTagLapseType.TURN_END, 1);
|
||||
}
|
||||
|
||||
onAdd(pokemon: Pokemon): void {
|
||||
@ -1117,23 +1140,26 @@ export class PowderTag extends BattlerTag {
|
||||
* @returns `true` if the tag should remain active.
|
||||
*/
|
||||
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
|
||||
const movePhase = globalScene.phaseManager.getCurrentPhase();
|
||||
if (lapseType !== BattlerTagLapseType.PRE_MOVE || !movePhase?.is("MovePhase")) {
|
||||
if (lapseType === BattlerTagLapseType.TURN_END) {
|
||||
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;
|
||||
if (
|
||||
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;
|
||||
}
|
||||
|
||||
// Disable the target's fire type move and damage it (subject to Magic Guard)
|
||||
movePhase.showMoveText();
|
||||
movePhase.fail();
|
||||
currentPhase.fail();
|
||||
|
||||
const idx = pokemon.getBattlerIndex();
|
||||
|
||||
@ -1233,13 +1259,7 @@ export class EncoreTag extends MoveRestrictionBattlerTag {
|
||||
public moveId: MoveId;
|
||||
|
||||
constructor(sourceId: number) {
|
||||
super(
|
||||
BattlerTagType.ENCORE,
|
||||
[BattlerTagLapseType.CUSTOM, BattlerTagLapseType.AFTER_MOVE],
|
||||
3,
|
||||
MoveId.ENCORE,
|
||||
sourceId,
|
||||
);
|
||||
super(BattlerTagType.ENCORE, BattlerTagLapseType.AFTER_MOVE, 3, MoveId.ENCORE, sourceId);
|
||||
}
|
||||
|
||||
public override loadTag(source: BaseBattlerTag & Pick<EncoreTag, "tagType" | "moveId">): void {
|
||||
@ -1737,7 +1757,7 @@ export class ProtectedTag extends BattlerTag {
|
||||
|
||||
// Stop multi-hit moves early
|
||||
const effectPhase = globalScene.phaseManager.getCurrentPhase();
|
||||
if (effectPhase?.is("MoveEffectPhase")) {
|
||||
if (effectPhase.is("MoveEffectPhase")) {
|
||||
effectPhase.stopMultiHit(pokemon);
|
||||
}
|
||||
return true;
|
||||
@ -2021,7 +2041,7 @@ export class UnburdenTag extends AbilityBattlerTag {
|
||||
export class TruantTag extends AbilityBattlerTag {
|
||||
public override readonly tagType = BattlerTagType.TRUANT;
|
||||
constructor() {
|
||||
super(BattlerTagType.TRUANT, AbilityId.TRUANT, BattlerTagLapseType.MOVE, 1);
|
||||
super(BattlerTagType.TRUANT, AbilityId.TRUANT, BattlerTagLapseType.CUSTOM, 1);
|
||||
}
|
||||
|
||||
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
|
||||
@ -2751,7 +2771,7 @@ export class GulpMissileTag extends SerializableBattlerTag {
|
||||
}
|
||||
|
||||
const moveEffectPhase = globalScene.phaseManager.getCurrentPhase();
|
||||
if (moveEffectPhase?.is("MoveEffectPhase")) {
|
||||
if (moveEffectPhase.is("MoveEffectPhase")) {
|
||||
const attacker = moveEffectPhase.getUserPokemon();
|
||||
|
||||
if (!attacker) {
|
||||
@ -2848,12 +2868,7 @@ export class ExposedTag extends SerializableBattlerTag {
|
||||
export class HealBlockTag extends MoveRestrictionBattlerTag {
|
||||
public override readonly tagType = BattlerTagType.HEAL_BLOCK;
|
||||
constructor(turnCount: number, sourceMove: MoveId) {
|
||||
super(
|
||||
BattlerTagType.HEAL_BLOCK,
|
||||
[BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END],
|
||||
turnCount,
|
||||
sourceMove,
|
||||
);
|
||||
super(BattlerTagType.HEAL_BLOCK, BattlerTagLapseType.TURN_END, turnCount, sourceMove);
|
||||
}
|
||||
|
||||
onActivation(pokemon: Pokemon): string {
|
||||
@ -3044,7 +3059,7 @@ export class SubstituteTag extends SerializableBattlerTag {
|
||||
constructor(sourceMove: MoveId, sourceId: number) {
|
||||
super(
|
||||
BattlerTagType.SUBSTITUTE,
|
||||
[BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.AFTER_MOVE, BattlerTagLapseType.HIT],
|
||||
[BattlerTagLapseType.MOVE, BattlerTagLapseType.AFTER_MOVE, BattlerTagLapseType.HIT],
|
||||
0,
|
||||
sourceMove,
|
||||
sourceId,
|
||||
@ -3102,7 +3117,7 @@ export class SubstituteTag extends SerializableBattlerTag {
|
||||
|
||||
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
|
||||
switch (lapseType) {
|
||||
case BattlerTagLapseType.PRE_MOVE:
|
||||
case BattlerTagLapseType.MOVE:
|
||||
this.onPreMove(pokemon);
|
||||
break;
|
||||
case BattlerTagLapseType.AFTER_MOVE:
|
||||
@ -3130,7 +3145,7 @@ export class SubstituteTag extends SerializableBattlerTag {
|
||||
/** If the Substitute redirects damage, queue a message to indicate it. */
|
||||
onHit(pokemon: Pokemon): void {
|
||||
const moveEffectPhase = globalScene.phaseManager.getCurrentPhase();
|
||||
if (moveEffectPhase?.is("MoveEffectPhase")) {
|
||||
if (moveEffectPhase.is("MoveEffectPhase")) {
|
||||
const attacker = moveEffectPhase.getUserPokemon();
|
||||
if (!attacker) {
|
||||
return;
|
||||
@ -3260,7 +3275,7 @@ export class TormentTag extends MoveRestrictionBattlerTag {
|
||||
export class TauntTag extends MoveRestrictionBattlerTag {
|
||||
public override readonly tagType = BattlerTagType.TAUNT;
|
||||
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) {
|
||||
@ -3315,13 +3330,7 @@ export class TauntTag extends MoveRestrictionBattlerTag {
|
||||
export class ImprisonTag extends MoveRestrictionBattlerTag {
|
||||
public override readonly tagType = BattlerTagType.IMPRISON;
|
||||
constructor(sourceId: number) {
|
||||
super(
|
||||
BattlerTagType.IMPRISON,
|
||||
[BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.AFTER_MOVE],
|
||||
1,
|
||||
MoveId.IMPRISON,
|
||||
sourceId,
|
||||
);
|
||||
super(BattlerTagType.IMPRISON, BattlerTagLapseType.AFTER_MOVE, 1, MoveId.IMPRISON, sourceId);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -3514,7 +3523,7 @@ export class PowerTrickTag extends SerializableBattlerTag {
|
||||
export class GrudgeTag extends SerializableBattlerTag {
|
||||
public override readonly tagType = BattlerTagType.GRUDGE;
|
||||
constructor() {
|
||||
super(BattlerTagType.GRUDGE, [BattlerTagLapseType.CUSTOM, BattlerTagLapseType.PRE_MOVE], 1, MoveId.GRUDGE);
|
||||
super(BattlerTagType.GRUDGE, BattlerTagLapseType.PRE_MOVE, 1, MoveId.GRUDGE);
|
||||
}
|
||||
|
||||
onAdd(pokemon: Pokemon) {
|
||||
@ -3535,23 +3544,23 @@ export class GrudgeTag extends SerializableBattlerTag {
|
||||
*/
|
||||
// TODO: Confirm whether this should interact with copying moves
|
||||
override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType, sourcePokemon?: Pokemon): boolean {
|
||||
if (lapseType === BattlerTagLapseType.CUSTOM && sourcePokemon) {
|
||||
if (sourcePokemon.isActive() && pokemon.isOpponent(sourcePokemon)) {
|
||||
const lastMove = pokemon.turnData.attacksReceived[0];
|
||||
const lastMoveData = sourcePokemon.getMoveset().find(m => m.moveId === lastMove.move);
|
||||
if (lastMoveData && lastMove.move !== MoveId.STRUGGLE) {
|
||||
lastMoveData.ppUsed = lastMoveData.getMovePp();
|
||||
globalScene.phaseManager.queueMessage(
|
||||
i18next.t("battlerTags:grudgeLapse", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
moveName: lastMoveData.getName(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
if (!sourcePokemon || lapseType !== BattlerTagLapseType.CUSTOM) {
|
||||
return super.lapse(pokemon, lapseType);
|
||||
}
|
||||
return super.lapse(pokemon, lapseType);
|
||||
if (sourcePokemon.isActive() && pokemon.isOpponent(sourcePokemon)) {
|
||||
const lastMove = pokemon.turnData.attacksReceived[0];
|
||||
const lastMoveData = sourcePokemon.getMoveset().find(m => m.moveId === lastMove.move);
|
||||
if (lastMoveData && lastMove.move !== MoveId.STRUGGLE) {
|
||||
lastMoveData.ppUsed = lastMoveData.getMovePp();
|
||||
globalScene.phaseManager.queueMessage(
|
||||
i18next.t("battlerTags:grudgeLapse", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(pokemon),
|
||||
moveName: lastMoveData.getName(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3780,8 +3789,6 @@ export function getBattlerTag(
|
||||
case BattlerTagType.ALWAYS_GET_HIT:
|
||||
case BattlerTagType.RECEIVE_DOUBLE_DAMAGE:
|
||||
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:
|
||||
return new GroundedTag(tagType, BattlerTagLapseType.CUSTOM, sourceMove);
|
||||
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 {
|
||||
const phase = globalScene.phaseManager.getCurrentPhase();
|
||||
if (phase?.is("MoveEffectPhase")) {
|
||||
if (phase.is("MoveEffectPhase")) {
|
||||
return {
|
||||
phase,
|
||||
attacker: phase.getPokemon(),
|
||||
@ -3955,7 +3962,6 @@ export type BattlerTagTypeMap = {
|
||||
[BattlerTagType.IGNORE_ACCURACY]: GenericSerializableBattlerTag<BattlerTagType.IGNORE_ACCURACY>;
|
||||
[BattlerTagType.ALWAYS_GET_HIT]: GenericSerializableBattlerTag<BattlerTagType.ALWAYS_GET_HIT>;
|
||||
[BattlerTagType.RECEIVE_DOUBLE_DAMAGE]: GenericSerializableBattlerTag<BattlerTagType.RECEIVE_DOUBLE_DAMAGE>;
|
||||
[BattlerTagType.BYPASS_SLEEP]: BattlerTag;
|
||||
[BattlerTagType.IGNORE_FLYING]: GroundedTag;
|
||||
[BattlerTagType.ROOSTED]: RoostedTag;
|
||||
[BattlerTagType.BURNED_UP]: RemovedTypeTag;
|
||||
|
264
src/data/moves/move-condition.ts
Normal file
264
src/data/moves/move-condition.ts
Normal 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",
|
||||
);
|
@ -1,6 +1,7 @@
|
||||
import { allMoves } from "#data/data-lists";
|
||||
import type { BattlerIndex } from "#enums/battler-index";
|
||||
import { BattlerTagType } from "#enums/battler-tag-type";
|
||||
import { MoveCategory, type MoveDamageCategory } from "#enums/move-category";
|
||||
import type { MoveId } from "#enums/move-id";
|
||||
import { MoveTarget } from "#enums/move-target";
|
||||
import { PokemonType } from "#enums/pokemon-type";
|
||||
@ -8,6 +9,7 @@ import type { Pokemon } from "#field/pokemon";
|
||||
import { applyMoveAttrs } from "#moves/apply-attrs";
|
||||
import type { Move, MoveTargetSet, UserMoveConditionFunc } from "#moves/move";
|
||||
import { NumberHolder } from "#utils/common";
|
||||
import { areAllies } from "#utils/pokemon-utils";
|
||||
|
||||
/**
|
||||
* Return whether the move targets the field
|
||||
@ -133,3 +135,25 @@ export const frenzyMissFunc: UserMoveConditionFunc = (user: Pokemon, move: Move)
|
||||
|
||||
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
@ -5,6 +5,7 @@ import type { Pokemon } from "#field/pokemon";
|
||||
import type { Move } from "#moves/move";
|
||||
import { applyChallenges } from "#utils/challenge-utils";
|
||||
import { BooleanHolder, toDmgValue } from "#utils/common";
|
||||
import i18next from "i18next";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 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`
|
||||
* @returns Whether this {@linkcode PokemonMove} can be selected by this Pokemon.
|
||||
* @param forSelection - Whether this is being checked for move selection; default `false`
|
||||
* @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 moveName = move.name;
|
||||
|
||||
// TODO: Add Sky Drop's 1 turn stall
|
||||
const usability = new BooleanHolder(
|
||||
!move.name.endsWith(" (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);
|
||||
if (this.moveId === MoveId.NONE || move.name.endsWith(" (N)")) {
|
||||
return [false, i18next.t("battle:moveNotImplemented", moveName.replace(" (N)", ""))];
|
||||
}
|
||||
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 {
|
||||
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}
|
||||
* @param count Amount of PP to use
|
||||
|
@ -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.
|
||||
* Each tag can have multiple different behaviors attached to different lapse types.
|
||||
@ -6,11 +8,23 @@ export enum BattlerTagLapseType {
|
||||
// TODO: This is unused...
|
||||
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
|
||||
*/
|
||||
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,
|
||||
/** Tag activates immediately after the holder's move finishes triggering (successful or not). */
|
||||
AFTER_MOVE,
|
||||
@ -32,6 +46,16 @@ export enum BattlerTagLapseType {
|
||||
* but still triggers on being KO'd.
|
||||
*/
|
||||
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,
|
||||
}
|
||||
|
||||
/** Same type as {@linkcode BattlerTagLapseType}, but excludes the {@linkcode BattlerTagLapseType.CUSTOM} type */
|
||||
export type NonCustomBattlerTagLapseType = Exclude<BattlerTagLapseType, BattlerTagLapseType.CUSTOM>;
|
||||
|
@ -47,7 +47,6 @@ export enum BattlerTagType {
|
||||
CRIT_BOOST = "CRIT_BOOST",
|
||||
ALWAYS_CRIT = "ALWAYS_CRIT",
|
||||
IGNORE_ACCURACY = "IGNORE_ACCURACY",
|
||||
BYPASS_SLEEP = "BYPASS_SLEEP",
|
||||
IGNORE_FLYING = "IGNORE_FLYING",
|
||||
SALT_CURED = "SALT_CURED",
|
||||
CURSED = "CURSED",
|
||||
|
@ -3,3 +3,6 @@ export enum MoveCategory {
|
||||
SPECIAL,
|
||||
STATUS,
|
||||
}
|
||||
|
||||
/** Type of damage categories */
|
||||
export type MoveDamageCategory = Exclude<MoveCategory, MoveCategory.STATUS>;
|
||||
|
@ -47,8 +47,8 @@ export enum MoveFlags {
|
||||
CHECK_ALL_HITS = 1 << 16,
|
||||
/** Indicates a move is able to bypass its target's Substitute (if the target has one) */
|
||||
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} */
|
||||
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,
|
||||
}
|
||||
|
@ -2410,8 +2410,8 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
|
||||
* modifiers from move and ability attributes
|
||||
* @param source - 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 simulated - Whether to apply abilities via simulated calls (defaults to `true`)
|
||||
* @param ignoreAbility - Whether to ignore abilities that might affect type effectiveness or immunity; default `false`
|
||||
* @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 useIllusion - Whether to consider an active illusion
|
||||
* @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);
|
||||
}
|
||||
|
||||
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();
|
||||
defendingSidePlayField.forEach((p: (typeof defendingSidePlayField)[0]) => {
|
||||
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;
|
||||
return move?.isUsable(this, ignorePp) ?? false;
|
||||
return move?.isUsable(this, ignorePp, true) ?? [false, ""];
|
||||
}
|
||||
|
||||
/** 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
|
||||
* @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 {
|
||||
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},
|
||||
* removing it if its duration goes below 0.
|
||||
* @param tagType - The `BattlerTagType` to lapse
|
||||
* @returns Whether the tag was present
|
||||
* Lapse the first {@linkcode BattlerTag} matching `tagType`
|
||||
*
|
||||
* @remarks
|
||||
* 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 tag = tags.find(t => t.tagType === tagType);
|
||||
if (!tag) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!tag.lapse(this, BattlerTagLapseType.CUSTOM)) {
|
||||
if (!tag.lapse(this, lapseType)) {
|
||||
tag.onRemove(this);
|
||||
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.
|
||||
* @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;
|
||||
tags
|
||||
.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
|
||||
*
|
||||
* @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
|
||||
* @returns `true` if the move is disabled for this Pokemon, otherwise `false`
|
||||
*
|
||||
* @see {@linkcode MoveRestrictionBattlerTag}
|
||||
*/
|
||||
public isMoveRestricted(moveId: MoveId, pokemon?: Pokemon): boolean {
|
||||
return this.getRestrictingTag(moveId, pokemon) !== null;
|
||||
// TODO: rename this method as it can be easily confused with a move being restricted
|
||||
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.
|
||||
// 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?
|
||||
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
|
||||
return queuedMove;
|
||||
}
|
||||
@ -6425,7 +6466,7 @@ export class EnemyPokemon extends Pokemon {
|
||||
this.summonData.moveQueue = [];
|
||||
|
||||
// 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 (movePool.length > 0) {
|
||||
// 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
|
||||
&& moveTargets.some(p => {
|
||||
const doesNotFail =
|
||||
move.applyConditions(this, p, move)
|
||||
move.applyConditions(this, p, -1)
|
||||
|| [MoveId.SUCKER_PUNCH, MoveId.UPPER_HAND, MoveId.THUNDERCLAP].includes(move.id);
|
||||
return (
|
||||
doesNotFail
|
||||
@ -6544,7 +6585,7 @@ export class EnemyPokemon extends Pokemon {
|
||||
* target score to -20
|
||||
*/
|
||||
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)
|
||||
) {
|
||||
targetScore = -20;
|
||||
@ -6882,6 +6923,10 @@ export class EnemyPokemon extends Pokemon {
|
||||
}
|
||||
|
||||
public getBattlerIndex(): BattlerIndex {
|
||||
const fieldIndex = this.getFieldIndex();
|
||||
if (fieldIndex === -1) {
|
||||
return BattlerIndex.ATTACKER;
|
||||
}
|
||||
return BattlerIndex.ENEMY + this.getFieldIndex();
|
||||
}
|
||||
|
||||
|
@ -9,7 +9,6 @@ import { ArenaTagType } from "#enums/arena-tag-type";
|
||||
import { BattleType } from "#enums/battle-type";
|
||||
import { BattlerTagType } from "#enums/battler-tag-type";
|
||||
import { BiomeId } from "#enums/biome-id";
|
||||
import { ChallengeType } from "#enums/challenge-type";
|
||||
import { Command } from "#enums/command";
|
||||
import { FieldPosition } from "#enums/field-position";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
@ -20,11 +19,8 @@ import { UiMode } from "#enums/ui-mode";
|
||||
import type { PlayerPokemon } from "#field/pokemon";
|
||||
import type { MoveTargetSet } from "#moves/move";
|
||||
import { getMoveTargets } from "#moves/move-utils";
|
||||
import type { PokemonMove } from "#moves/pokemon-move";
|
||||
import { FieldPhase } from "#phases/field-phase";
|
||||
import type { TurnMove } from "#types/turn-move";
|
||||
import { applyChallenges } from "#utils/challenge-utils";
|
||||
import { BooleanHolder } from "#utils/common";
|
||||
import i18next from "i18next";
|
||||
|
||||
export class CommandPhase extends FieldPhase {
|
||||
@ -127,7 +123,7 @@ export class CommandPhase extends FieldPhase {
|
||||
if (
|
||||
queuedMove.move !== MoveId.NONE
|
||||
&& !isVirtual(queuedMove.useMode)
|
||||
&& !movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode))
|
||||
&& !movesetQueuedMove?.isUsable(playerPokemon, isIgnorePP(queuedMove.useMode), true)
|
||||
) {
|
||||
entriesToDelete++;
|
||||
} else {
|
||||
@ -205,39 +201,18 @@ export class CommandPhase extends FieldPhase {
|
||||
}
|
||||
|
||||
/**
|
||||
* Submethod of {@linkcode handleFightCommand} responsible for queuing the appropriate
|
||||
* error message when a move cannot be used.
|
||||
* @param user - The pokemon using the move
|
||||
* @param move - The move that cannot be used
|
||||
* Submethod of {@linkcode handleFightCommand} responsible for queuing the provided error message when the move cannot be used
|
||||
* @param msg - The reason why the move cannot be used
|
||||
*/
|
||||
private queueFightErrorMessage(user: PlayerPokemon, move: PokemonMove) {
|
||||
globalScene.ui.setMode(UiMode.MESSAGE);
|
||||
|
||||
// Set the translation key for why the move cannot be selected
|
||||
let cannotSelectKey: string;
|
||||
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 }),
|
||||
private queueFightErrorMessage(msg: string) {
|
||||
const ui = globalScene.ui;
|
||||
ui.setMode(UiMode.MESSAGE);
|
||||
ui.showText(
|
||||
msg,
|
||||
null,
|
||||
() => {
|
||||
globalScene.ui.clearText();
|
||||
globalScene.ui.setMode(UiMode.FIGHT, this.fieldIndex);
|
||||
ui.clearText();
|
||||
ui.setMode(UiMode.FIGHT, this.fieldIndex);
|
||||
},
|
||||
null,
|
||||
true,
|
||||
@ -274,22 +249,16 @@ export class CommandPhase extends FieldPhase {
|
||||
): boolean {
|
||||
const playerPokemon = this.getPokemon();
|
||||
const ignorePP = isIgnorePP(useMode);
|
||||
|
||||
let canUse = cursor === -1 || playerPokemon.trySelectMove(cursor, ignorePP);
|
||||
|
||||
const moveset = playerPokemon.getMoveset();
|
||||
const [canUse, reason] = cursor === -1 ? [true, ""] : playerPokemon.trySelectMove(cursor, ignorePP);
|
||||
|
||||
// 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) {
|
||||
// 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]);
|
||||
}
|
||||
if (!canUse && !useStruggle) {
|
||||
console.error("Cannot use move:", reason);
|
||||
this.queueFightErrorMessage(reason);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -20,7 +20,7 @@ export class MoveHeaderPhase extends BattlePhase {
|
||||
}
|
||||
|
||||
canMove(): boolean {
|
||||
return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon);
|
||||
return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon)[0];
|
||||
}
|
||||
|
||||
start() {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -74,7 +74,7 @@ export class CommandUiHandler extends UiHandler {
|
||||
|
||||
let commandPhase: CommandPhase;
|
||||
const currentPhase = globalScene.phaseManager.getCurrentPhase();
|
||||
if (currentPhase?.is("CommandPhase")) {
|
||||
if (currentPhase.is("CommandPhase")) {
|
||||
commandPhase = currentPhase;
|
||||
} else {
|
||||
commandPhase = globalScene.phaseManager.getStandbyPhase() as CommandPhase;
|
||||
|
@ -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 { POKERUS_STARTER_COUNT, speciesStarterCosts } from "#balance/starters";
|
||||
import { allSpecies } from "#data/data-lists";
|
||||
import type { PokemonSpecies, PokemonSpeciesForm } from "#data/pokemon-species";
|
||||
import { BattlerIndex } from "#enums/battler-index";
|
||||
import type { SpeciesId } from "#enums/species-id";
|
||||
import { randSeedItem } from "./common";
|
||||
|
||||
@ -123,3 +128,20 @@ export function getPokemonSpeciesForm(species: SpeciesId, formIndex: number): Po
|
||||
}
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { CudChewConsumeBerryAbAttr } from "#abilities/ability";
|
||||
import { globalScene } from "#app/global-scene";
|
||||
import { getPokemonNameWithAffix } from "#app/messages";
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
@ -6,7 +5,6 @@ import { BerryType } from "#enums/berry-type";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import { Stat } from "#enums/stat";
|
||||
import { Pokemon } from "#field/pokemon";
|
||||
import { GameManager } from "#test/test-utils/game-manager";
|
||||
import i18next from "i18next";
|
||||
import Phaser from "phaser";
|
||||
@ -111,7 +109,6 @@ describe("Abilities - Cud Chew", () => {
|
||||
|
||||
it("can store multiple berries across 2 turns with teatime", async () => {
|
||||
// always eat first berry for stuff cheeks & company
|
||||
vi.spyOn(Pokemon.prototype, "randBattleSeedInt").mockReturnValue(0);
|
||||
game.override
|
||||
.startingHeldItems([
|
||||
{ name: "BERRY", type: BerryType.PETAYA, count: 3 },
|
||||
@ -121,7 +118,10 @@ describe("Abilities - Cud Chew", () => {
|
||||
await game.classicMode.startBattle([SpeciesId.FARIGIRAF]);
|
||||
|
||||
const farigiraf = game.field.getPlayerPokemon();
|
||||
const enemy = game.field.getEnemyPokemon();
|
||||
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);
|
||||
await game.toNextTurn();
|
||||
@ -196,10 +196,10 @@ describe("Abilities - Cud Chew", () => {
|
||||
|
||||
describe("regurgiates berries", () => {
|
||||
it("re-triggers effects on eater without pushing to array", async () => {
|
||||
const apply = vi.spyOn(CudChewConsumeBerryAbAttr.prototype, "apply");
|
||||
await game.classicMode.startBattle([SpeciesId.FARIGIRAF]);
|
||||
|
||||
const farigiraf = game.field.getPlayerPokemon();
|
||||
const apply = vi.spyOn(farigiraf.getAbilityAttrs("CudChewConsumeBerryAbAttr")[0], "apply");
|
||||
farigiraf.hp = 1;
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
|
@ -142,7 +142,7 @@ describe("BattlerTag - SubstituteTag", () => {
|
||||
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);
|
||||
|
||||
vi.spyOn(mockPokemon.scene as BattleScene, "triggerPokemonBattleAnim").mockImplementation(
|
||||
@ -154,7 +154,7 @@ describe("BattlerTag - SubstituteTag", () => {
|
||||
|
||||
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((mockPokemon.scene as BattleScene).triggerPokemonBattleAnim).toHaveBeenCalledTimes(1);
|
||||
|
@ -43,8 +43,10 @@ describe("Challenges - Hardcore", () => {
|
||||
await game.challengeMode.startBattle([SpeciesId.NUZLEAF]);
|
||||
|
||||
const player = game.field.getPlayerPokemon();
|
||||
const enemy = game.field.getEnemyPokemon();
|
||||
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);
|
||||
await game.toEndOfTurn();
|
||||
|
49
test/moves/belch.test.ts
Normal file
49
test/moves/belch.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
@ -35,7 +35,7 @@ describe("Moves - Copycat", () => {
|
||||
.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);
|
||||
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
|
||||
|
||||
@ -43,6 +43,7 @@ describe("Moves - Copycat", () => {
|
||||
await game.toNextTurn();
|
||||
|
||||
game.move.select(MoveId.COPYCAT); // Last successful move should be Swords Dance
|
||||
await game.move.forceEnemyMove(MoveId.SPLASH);
|
||||
await game.toNextTurn();
|
||||
|
||||
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(4);
|
||||
|
@ -90,7 +90,7 @@ describe("Moves - Dig", () => {
|
||||
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);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
@ -104,7 +104,7 @@ describe("Moves - Dig", () => {
|
||||
expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP);
|
||||
|
||||
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 () => {
|
||||
|
@ -74,7 +74,7 @@ describe("Moves - Dive", () => {
|
||||
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);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
@ -88,7 +88,7 @@ describe("Moves - Dive", () => {
|
||||
expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP);
|
||||
|
||||
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 () => {
|
||||
|
@ -85,6 +85,7 @@ describe("Moves - Fly", () => {
|
||||
const playerPokemon = game.field.getPlayerPokemon();
|
||||
|
||||
game.move.select(MoveId.FLY);
|
||||
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
|
||||
|
||||
await game.phaseInterceptor.to("TurnEndPhase");
|
||||
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined();
|
||||
@ -94,7 +95,7 @@ describe("Moves - Fly", () => {
|
||||
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]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
@ -115,6 +116,6 @@ describe("Moves - Fly", () => {
|
||||
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
|
||||
|
||||
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
|
||||
expect(playerFly?.ppUsed).toBe(0);
|
||||
expect(playerFly?.ppUsed).toBe(1);
|
||||
});
|
||||
});
|
||||
|
@ -33,7 +33,7 @@ describe("Moves - Gigaton Hammer", () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@ -46,17 +46,17 @@ describe("Moves - Gigaton Hammer", () => {
|
||||
await game.doKillOpponents();
|
||||
await game.toNextWave();
|
||||
|
||||
// Attempting to use Gigaton Hammer again should result in struggle
|
||||
game.move.select(MoveId.GIGATON_HAMMER);
|
||||
await game.toNextTurn();
|
||||
|
||||
const enemy2 = game.field.getEnemyPokemon();
|
||||
|
||||
expect(enemy2.hp).toBe(enemy2.getMaxHp());
|
||||
const player = game.field.getPlayerPokemon();
|
||||
expect(player.getLastXMoves()[0]?.move).toBe(MoveId.STRUGGLE);
|
||||
});
|
||||
|
||||
it("can be used again if recalled and sent back out", async () => {
|
||||
game.override.startingWave(4);
|
||||
await game.classicMode.startBattle();
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const enemy1 = game.field.getEnemyPokemon();
|
||||
|
||||
|
@ -97,9 +97,16 @@ describe("Moves - Sleep Talk", () => {
|
||||
|
||||
game.move.select(MoveId.SLEEP_TALK);
|
||||
await game.toNextTurn();
|
||||
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK));
|
||||
});
|
||||
|
||||
const feebas = game.field.getPlayerPokemon();
|
||||
expect(feebas.getStatStage(Stat.SPD)).toBe(1);
|
||||
expect(feebas.getStatStage(Stat.DEF)).toBe(-1);
|
||||
it("should apply secondary effects of a move", async () => {
|
||||
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
|
||||
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
102
test/moves/teleport.test.ts
Normal 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);
|
||||
});
|
||||
});
|
@ -35,6 +35,7 @@ describe("Moves - Throat Chop", () => {
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const enemy = game.field.getEnemyPokemon();
|
||||
const player = game.field.getPlayerPokemon();
|
||||
|
||||
game.move.select(MoveId.GROWL);
|
||||
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
|
||||
@ -46,6 +47,7 @@ describe("Moves - Throat Chop", () => {
|
||||
// Second turn, struggle if no valid moves
|
||||
await game.toNextTurn();
|
||||
|
||||
expect(player.trySelectMove(MoveId.GROWL)[0]).toBe(false);
|
||||
game.move.select(MoveId.GROWL);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
|
||||
|
@ -84,11 +84,11 @@ describe("UI - Type Hints", () => {
|
||||
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
|
||||
.enemySpecies(SpeciesId.ABRA)
|
||||
.moveset([MoveId.SPLASH, MoveId.SHADOW_BALL, MoveId.SOAK])
|
||||
.enemyMoveset([MoveId.SPLASH, MoveId.TELEPORT])
|
||||
.enemyMoveset([MoveId.SPLASH, MoveId.MEMENTO])
|
||||
.battleStyle("double");
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
|
||||
@ -97,7 +97,7 @@ describe("UI - Type Hints", () => {
|
||||
game.move.select(MoveId.SOAK, 1);
|
||||
|
||||
await game.move.selectEnemyMove(MoveId.SPLASH);
|
||||
await game.move.selectEnemyMove(MoveId.TELEPORT);
|
||||
await game.move.selectEnemyMove(MoveId.MEMENTO);
|
||||
await game.toNextTurn();
|
||||
|
||||
game.onNextPrompt("CommandPhase", UiMode.COMMAND, () => {
|
||||
|
Loading…
Reference in New Issue
Block a user