Compare commits

...

7 Commits

Author SHA1 Message Date
Bertie690
dbf6e73253
Merge d87c301251 into 1ff2701964 2025-06-19 22:29:37 -04:00
lnuvy
1ff2701964
[Bug] Fix when using arrow keys in Pokédex after catching a Pokémon from mystery event (#6000)
fix: wrap setOverlayMode args in array to mystery-encounter

Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com>
2025-06-19 20:45:54 -04:00
Bertie690
1e306e25b5
[Move] Fixed Chilly Reception displaying message when used virtually
https://github.com/pagefaultgames/pokerogue/pull/5843

* Fixed Chilly Reception displaying message when used virtually

* Fixed lack of message causing Chilly Reception to fail

* Fixed tests

* Reverted bool change + fixed test

* Fixed test
2025-06-19 17:14:05 -07:00
Madmadness65
43aa772603
[UI/UX] Add Pokémon category flavor text to Pokédex (#5957)
* Add Pokémon category flavor text to Pokédex

* Append `_category` to locale entry
2025-06-20 00:04:57 +00:00
Bertie690
d87c301251 Fixed com 2025-06-16 14:56:38 -04:00
Bertie690
2b88310a68 Fixed a few docs 2025-06-16 11:50:27 -04:00
Bertie690
98b80f30cd Mostly implemented Future Sight/Doom Desire 2025-06-16 11:50:26 -04:00
16 changed files with 675 additions and 234 deletions

View File

@ -22,6 +22,14 @@ import { MoveId } from "#enums/move-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { MoveUseMode } from "#enums/move-use-mode";
/** biome-ignore lint/correctness/noUnusedImports: Type-only import for doc comment */
import type { BattlerTag } from "#app/data/battler-tags";
/**
* An {@linkcode ArenaTag} represents a semi-persistent effect affecting a given side of the field.
* Unlike {@linkcode BattlerTag}s (which are tied to individual {@linkcode Pokemon}), `ArenaTag`s function independently of
* the Pokemon currently on-field, only cleared on arena reset or through their respective {@linkcode ArenaTag.lapse | lapse} methods.
*/
export abstract class ArenaTag {
constructor(
public tagType: ArenaTagType,
@ -852,44 +860,104 @@ class ToxicSpikesTag extends ArenaTrapTag {
}
/**
* Arena Tag class for delayed attacks, such as {@linkcode MoveId.FUTURE_SIGHT} or {@linkcode MoveId.DOOM_DESIRE}.
* Delays the attack's effect by a set amount of turns, usually 3 (including the turn the move is used),
* and deals damage after the turn count is reached.
* Interface representing a delayed attack command.
* @see {@linkcode DelayedAttackTag}
*/
interface DelayedAttack {
sourceId: number;
move: MoveId;
targetIndex: BattlerIndex;
turnCount: number;
}
/**
* Arena Tag to manage execution of delayed attacks, such as {@linkcode MoveId.FUTURE_SIGHT} or {@linkcode MoveId.DOOM_DESIRE}.
* Delayed attacks do nothing for the first 3 turns after use (including the turn the move is used),
* dealing damage to the specified slot after the turn count has been elapsed.
*/
export class DelayedAttackTag extends ArenaTag {
public targetIndex: BattlerIndex;
/** Contains all queued delayed attacks on the field */
private delayedAttacks: DelayedAttack[] = [];
constructor(
tagType: ArenaTagType,
sourceMove: MoveId | undefined,
sourceId: number,
targetIndex: BattlerIndex,
side: ArenaTagSide = ArenaTagSide.BOTH,
) {
super(tagType, 3, sourceMove, sourceId, side);
this.targetIndex = targetIndex;
this.side = side;
constructor() {
super(ArenaTagType.DELAYED_ATTACK, 0);
}
lapse(arena: Arena): boolean {
const ret = super.lapse(arena);
loadTag(source: ArenaTag | any): void {
super.loadTag(source);
this.delayedAttacks = source.delayedAttacks;
}
/**
* Queue a delayed attack to be used in some indeterminate number of turns.
* @param source - The {@linkcode Pokemon} using the move
* @param move - The {@linkcode MoveId} being used
* @param targetIndex - The {@linkcode BattlerIndex} being targeted
* @param turnCount - The number of turns to delay the attack; default `3`
*/
public queueAttack(source: Pokemon, move: MoveId, targetIndex: BattlerIndex, turnCount = 3): void {
this.delayedAttacks.push({ sourceId: source.id, move, targetIndex, turnCount });
}
/**
* Check whether a delayed attack can be queued against the given target.
* @param targetIndex - The {@linkcode BattlerIndex} of the target Pokemon.
*/
public canAddAttack(targetIndex: BattlerIndex): boolean {
return this.delayedAttacks.every(atk => atk.targetIndex !== targetIndex);
}
/**
* Tick down all existing delayed attacks, activating them if their timers have elapsed.
* @returns `true` if at least 1 delayed attack has not been completed
*/
override lapse(_arena: Arena): boolean {
for (const attack of this.delayedAttacks) {
const source = globalScene.getPokemonById(attack.sourceId);
const target: Pokemon | null = globalScene.getField()[attack.targetIndex];
if (--attack.turnCount > 0) {
// attack still cooking
continue;
}
if (!source || !target || source === target || target.isFainted()) {
// source/target either not present or the exact same pokemon; silently mark for deletion
attack.turnCount = -1;
continue;
}
// Queue attack message and then unshift a new MoveEffectPhase for this move's attack phase.
globalScene.phaseManager.queueMessage(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(target),
moveName: allMoves[attack.move].name,
}),
);
if (!ret) {
// TODO: This should not add to move history (for Spite)
globalScene.phaseManager.unshiftNew(
"MoveEffectPhase",
this.sourceId!,
[this.targetIndex],
allMoves[this.sourceMove!],
MoveUseMode.FOLLOW_UP,
); // TODO: are those bangs correct?
attack.sourceId,
[attack.targetIndex],
allMoves[attack.move],
MoveUseMode.TRANSPARENT,
);
}
return ret;
return this.removeDoneAttacks();
}
onRemove(_arena: Arena): void {}
/**
* Remove all finished attacks from the current queue.
* @returns Whether at least 1 attack has not finished triggering
*/
removeDoneAttacks(): boolean {
this.delayedAttacks = this.delayedAttacks.filter(a => a.turnCount > 0);
return this.delayedAttacks.length > 0;
}
/** Override on remove func to do nothing. */
override onRemove(_arena: Arena): void {}
}
/**
@ -1481,7 +1549,6 @@ export function getArenaTag(
turnCount: number,
sourceMove: MoveId | undefined,
sourceId: number,
targetIndex?: BattlerIndex,
side: ArenaTagSide = ArenaTagSide.BOTH,
): ArenaTag | null {
switch (tagType) {
@ -1507,9 +1574,8 @@ export function getArenaTag(
return new SpikesTag(sourceId, side);
case ArenaTagType.TOXIC_SPIKES:
return new ToxicSpikesTag(sourceId, side);
case ArenaTagType.FUTURE_SIGHT:
case ArenaTagType.DOOM_DESIRE:
return new DelayedAttackTag(tagType, sourceMove, sourceId, targetIndex!, side); // TODO:questionable bang
case ArenaTagType.DELAYED_ATTACK:
return new DelayedAttackTag();
case ArenaTagType.WISH:
return new WishTag(turnCount, sourceId, side);
case ArenaTagType.STEALTH_ROCK:
@ -1556,14 +1622,7 @@ export function getArenaTag(
*/
export function loadArenaTag(source: ArenaTag | any): ArenaTag {
const tag =
getArenaTag(
source.tagType,
source.turnCount,
source.sourceMove,
source.sourceId,
source.targetIndex,
source.side,
) ?? new NoneTag();
getArenaTag(source.tagType, source.sourceId, source.sourceMove, source.turnCount, source.side) ?? new NoneTag();
tag.loadTag(source);
return tag;
}

View File

@ -30,7 +30,7 @@ import { PokemonType } from "#enums/pokemon-type";
import { BooleanHolder, NumberHolder, isNullOrUndefined, toDmgValue, randSeedItem, randSeedInt, getEnumValues, toReadableString, type Constructor, randSeedFloat } from "#app/utils/common";
import { WeatherType } from "#enums/weather-type";
import type { ArenaTrapTag } from "../arena-tag";
import { WeakenMoveTypeTag } from "../arena-tag";
import { DelayedAttackTag, WeakenMoveTypeTag } from "../arena-tag";
import { ArenaTagSide } from "#enums/arena-tag-side";
import {
applyAbAttrs,
@ -93,6 +93,10 @@ import { ChargingMove, MoveAttrMap, MoveAttrString, MoveKindString, MoveClassMap
import { applyMoveAttrs } from "./apply-attrs";
import { frenzyMissFunc, getMoveTargets } from "./move-utils";
/**
* A function used to conditionally determine execution of a given {@linkcode MoveAttr}.
* Conventionally returns `true` for success and `false` for failure.
*/
type MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => boolean;
export type UserMoveConditionFunc = (user: Pokemon, move: Move) => boolean;
@ -1390,18 +1394,31 @@ export class BeakBlastHeaderAttr extends AddBattlerTagHeaderAttr {
}
}
/**
* Attribute to display a message before a move is executed.
*/
export class PreMoveMessageAttr extends MoveAttr {
private message: string | ((user: Pokemon, target: Pokemon, move: Move) => string);
/** The message to display or a function returning one */
private message: string | ((user: Pokemon, target: Pokemon, move: Move) => string | undefined);
/**
* Create a new {@linkcode PreMoveMessageAttr} to display a message before move execution.
* @param message - The message to display before move use, either as a string or a function producing one.
* @remarks
* If {@linkcode message} evaluates to an empty string (`''`), no message will be displayed
* (though the move will still succeed).
*/
constructor(message: string | ((user: Pokemon, target: Pokemon, move: Move) => string)) {
super();
this.message = message;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const message = typeof this.message === "string"
? this.message as string
: this.message(user, target, move);
apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean {
const message = typeof this.message === "function"
? this.message(user, target, move)
: this.message;
// TODO: Consider changing if/when MoveAttr `apply` return values become significant
if (message) {
globalScene.phaseManager.queueMessage(message, 500);
return true;
@ -3077,52 +3094,80 @@ export class OverrideMoveEffectAttr extends MoveAttr {
* Its sole purpose is to ensure that typescript is able to properly narrow when the `is` method is called.
*/
declare private _: never;
override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
/**
* Apply the move attribute to override a move effect.
* @param user - The {@linkcode Pokemon} using the move
* @param target - The {@linkcode Pokemon} targeted by the move
* @param move - The {@linkcode Move} being used
* @param args -
* `[0]`: A {@linkcode BooleanHolder} containing whether move effects were successfully overridden; should be set to `true` on success
* `[1]`: The {@linkcode MoveUseMode} dictating how this move was used.
* @returns `true` if the move effect was successfully overridden.
*/
override apply(_user: Pokemon, _target: Pokemon, _move: Move, _args: [overridden: BooleanHolder, useMode: MoveUseMode]): boolean {
return true;
}
}
/**
* Attack Move that doesn't hit the turn it is played and doesn't allow for multiple
* uses on the same target. Examples are Future Sight or Doom Desire.
* @extends OverrideMoveEffectAttr
* @param tagType The {@linkcode ArenaTagType} that will be placed on the field when the move is used
* @param chargeAnim The {@linkcode ChargeAnim | Charging Animation} used for the move
* @param chargeText The text to display when the move is used
* Attribute to implement delayed attacks, such as {@linkcode MoveId.FUTURE_SIGHT} or {@linkcode MoveId.DOOM_DESIRE}.
* Delays the attack's effect by adding an arena tag,
* only after the turn count is fully elapsed.
*/
export class DelayedAttackAttr extends OverrideMoveEffectAttr {
public tagType: ArenaTagType;
public chargeAnim: ChargeAnim;
private chargeText: string;
constructor(tagType: ArenaTagType, chargeAnim: ChargeAnim, chargeText: string) {
/**
* @param chargeAnim - The {@linkcode ChargeAnim | charging animation} used for the move's charging phase.
* @param chargeKey - The `i18next` locales **key** to show when the delayed attack is used.
* In the displayed text, `{{pokemonName}}` and `{{targetName}}` will be populated with the user's & target's names respectively.
*/
constructor(chargeAnim: ChargeAnim, chargeKey: string) {
super();
this.tagType = tagType;
this.chargeAnim = chargeAnim;
this.chargeText = chargeText;
this.chargeText = chargeKey;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
// Edge case for the move applied on a pokemon that has fainted
if (!target) {
getCondition(): MoveConditionFunc {
return (_user, target, _move) => {
// Check the arena if another delayed attack is active and hitting the same slot
const delayedTag = globalScene.arena.getTag(DelayedAttackTag);
return delayedTag?.canAddAttack(target.getBattlerIndex()) ?? true;
}
}
apply(user: Pokemon, target: Pokemon, move: Move, args: [overridden: BooleanHolder, useMode: MoveUseMode]): boolean {
const useMode = args[1];
if (useMode === MoveUseMode.TRANSPARENT) {
// don't trigger if already queueing an indirect attack
return true;
}
const overridden = args[0] as BooleanHolder;
const virtual = args[1] as boolean;
const overridden = args[0];
overridden.value = true;
if (!virtual) {
overridden.value = true;
globalScene.phaseManager.unshiftNew("MoveAnimPhase", new MoveChargeAnim(this.chargeAnim, move.id, user));
globalScene.phaseManager.queueMessage(this.chargeText.replace("{TARGET}", getPokemonNameWithAffix(target)).replace("{USER}", getPokemonNameWithAffix(user)));
user.pushMoveHistory({ move: move.id, targets: [ target.getBattlerIndex() ], result: MoveResult.OTHER, useMode: MoveUseMode.NORMAL });
const side = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
globalScene.arena.addTag(this.tagType, 3, move.id, user.id, side, false, target.getBattlerIndex());
} else {
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(globalScene.getPokemonById(target.id) ?? undefined), moveName: move.name }));
// Display the move animation to foresee an attack
globalScene.phaseManager.unshiftNew("MoveAnimPhase", new MoveChargeAnim(this.chargeAnim, move.id, user));
globalScene.phaseManager.queueMessage(i18next.t(this.chargeText,
// uncomment if any new delayed moves actually use target in the move text.
{pokemonName: getPokemonNameWithAffix(user)/*, targetName: getPokemonNameWithAffix(target) */}))
user.pushMoveHistory({move: move.id, targets: [target.getBattlerIndex()], result: MoveResult.OTHER, useMode: useMode, turn: globalScene.currentBattle.turn})
// Add a Delayed Attack tag to the arena if it doesn't already exist and queue up an extra attack.
// TODO: Remove unused params once signature is tweaked to make more sense (none of these get used)
globalScene.arena.addTag(ArenaTagType.DELAYED_ATTACK, 123, 69, 420);
// Queue an attack on the added (or existing) tag
const delayedAttackTag = globalScene.arena.getTag(DelayedAttackTag)
if (!delayedAttackTag) {
console.warn("Delayed attack tag not present!")
return false;
}
delayedAttackTag.queueAttack(user, move.id, target.getBattlerIndex());
return true;
}
}
@ -3142,8 +3187,8 @@ export class AwaitCombinedPledgeAttr extends OverrideMoveEffectAttr {
* @param user the {@linkcode Pokemon} using this move
* @param target n/a
* @param move the {@linkcode Move} being used
* @param args
* - [0] a {@linkcode BooleanHolder} indicating whether the move's base
* @param args -
* `[0]`: A {@linkcode BooleanHolder} indicating whether the move's base
* effects should be overridden this turn.
* @returns `true` if base move effects were overridden; `false` otherwise
*/
@ -9177,9 +9222,13 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.SPDEF ], -1)
.ballBombMove(),
new AttackMove(MoveId.FUTURE_SIGHT, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 120, 100, 10, -1, 0, 2)
.partial() // cannot be used on multiple Pokemon on the same side in a double battle, hits immediately when called by Metronome/etc, should not apply abilities or held items if user is off the field
.attr(DelayedAttackAttr, ChargeAnim.FUTURE_SIGHT_CHARGING, "moveTriggers:foresawAnAttack")
.ignoresProtect()
.attr(DelayedAttackAttr, ArenaTagType.FUTURE_SIGHT, ChargeAnim.FUTURE_SIGHT_CHARGING, i18next.t("moveTriggers:foresawAnAttack", { pokemonName: "{USER}" })),
/*
* Should not apply abilities or held items if user is off the field
* Triggered move phase occurs after Electrify tag is removed
*/
.edgeCase(),
new AttackMove(MoveId.ROCK_SMASH, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 40, 100, 15, 50, 0, 2)
.attr(StatStageChangeAttr, [ Stat.DEF ], -1),
new AttackMove(MoveId.WHIRLPOOL, PokemonType.WATER, MoveCategory.SPECIAL, 35, 85, 15, -1, 0, 2)
@ -9515,9 +9564,13 @@ export function initMoves() {
.attr(ConfuseAttr)
.pulseMove(),
new AttackMove(MoveId.DOOM_DESIRE, PokemonType.STEEL, MoveCategory.SPECIAL, 140, 100, 5, -1, 0, 3)
.partial() // cannot be used on multiple Pokemon on the same side in a double battle, hits immediately when called by Metronome/etc, should not apply abilities or held items if user is off the field
.attr(DelayedAttackAttr, ChargeAnim.DOOM_DESIRE_CHARGING, "moveTriggers:choseDoomDesireAsDestiny")
.ignoresProtect()
.attr(DelayedAttackAttr, ArenaTagType.DOOM_DESIRE, ChargeAnim.DOOM_DESIRE_CHARGING, i18next.t("moveTriggers:choseDoomDesireAsDestiny", { pokemonName: "{USER}" })),
/*
* Should not apply abilities or held items if user is off the field
* Triggered move phase occurs after Electrify tag is removed
*/
.edgeCase(),
new AttackMove(MoveId.PSYCHO_BOOST, PokemonType.PSYCHIC, MoveCategory.SPECIAL, 140, 90, 5, -1, 0, 3)
.attr(StatStageChangeAttr, [ Stat.SPATK ], -2, true),
new SelfStatusMove(MoveId.ROOST, PokemonType.FLYING, -1, 5, -1, 0, 4)
@ -11299,7 +11352,11 @@ export function initMoves() {
.attr(ForceSwitchOutAttr, true, SwitchType.SHED_TAIL)
.condition(failIfLastInPartyCondition),
new SelfStatusMove(MoveId.CHILLY_RECEPTION, PokemonType.ICE, -1, 10, -1, 0, 9)
.attr(PreMoveMessageAttr, (user, move) => i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(user) }))
.attr(PreMoveMessageAttr, (user, _target, _move) =>
// Don't display text if current move phase is follow up (ie move called indirectly)
isVirtual((globalScene.phaseManager.getCurrentPhase() as MovePhase).useMode)
? ""
: i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(user) }))
.attr(ChillyReceptionAttr, true),
new SelfStatusMove(MoveId.TIDY_UP, PokemonType.NORMAL, -1, 10, -1, 0, 9)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPD ], 1, true)

View File

@ -751,7 +751,7 @@ export async function catchPokemon(
UiMode.POKEDEX_PAGE,
pokemon.species,
pokemon.formIndex,
attributes,
[attributes],
null,
() => {
globalScene.ui.setMode(UiMode.MESSAGE).then(() => {

View File

@ -764,7 +764,7 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali
readonly subLegendary: boolean;
readonly legendary: boolean;
readonly mythical: boolean;
readonly species: string;
public category: string;
readonly growthRate: GrowthRate;
/** The chance (as a decimal) for this Species to be male, or `null` for genderless species */
readonly malePercent: number | null;
@ -778,7 +778,7 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali
subLegendary: boolean,
legendary: boolean,
mythical: boolean,
species: string,
category: string,
type1: PokemonType,
type2: PokemonType | null,
height: number,
@ -829,7 +829,7 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali
this.subLegendary = subLegendary;
this.legendary = legendary;
this.mythical = mythical;
this.species = species;
this.category = category;
this.growthRate = growthRate;
this.malePercent = malePercent;
this.genderDiffs = genderDiffs;
@ -968,6 +968,7 @@ export default class PokemonSpecies extends PokemonSpeciesForm implements Locali
localize(): void {
this.name = i18next.t(`pokemon:${SpeciesId[this.speciesId].toLowerCase()}`);
this.category = i18next.t(`pokemonCategory:${SpeciesId[this.speciesId].toLowerCase()}_category`);
}
getWildSpeciesForLevel(level: number, allowEvolving: boolean, isBoss: boolean, gameMode: GameMode): SpeciesId {

View File

@ -5,8 +5,7 @@ export enum ArenaTagType {
SPIKES = "SPIKES",
TOXIC_SPIKES = "TOXIC_SPIKES",
MIST = "MIST",
FUTURE_SIGHT = "FUTURE_SIGHT",
DOOM_DESIRE = "DOOM_DESIRE",
DELAYED_ATTACK = "DELAYED_ATTACK",
WISH = "WISH",
STEALTH_ROCK = "STEALTH_ROCK",
STICKY_WEB = "STICKY_WEB",

View File

@ -1,5 +1,6 @@
import type { PostDancingMoveAbAttr } from "#app/data/abilities/ability";
import type { BattlerTagLapseType } from "#enums/battler-tag-lapse-type";
import type { DelayedAttackAttr } from "#app/@types/move-types";
/**
* Enum representing all the possible means through which a given move can be executed.
@ -59,8 +60,15 @@ export const MoveUseMode = {
* and retain the same copy prevention as {@linkcode MoveUseMode.FOLLOW_UP}, but additionally
* **cannot be reflected by other reflecting effects**.
*/
REFLECTED: 5
// TODO: Add use type TRANSPARENT for Future Sight and Doom Desire to prevent move history pushing
REFLECTED: 5,
/**
* This "move" was created by a transparent effect that **does not count as using a move**,
* such as {@linkcode DelayedAttackAttr | Future Sight/Doom Desire}.
*
* In addition to inheriting the cancellation ignores and copy prevention from {@linkcode MoveUseMode.REFLECTED},
* transparent moves are ignored by **all forms of move usage checks** due to **not pushing to move history**.
*/
TRANSPARENT: 6
} as const;
export type MoveUseMode = (typeof MoveUseMode)[keyof typeof MoveUseMode];

View File

@ -687,15 +687,15 @@ export class Arena {
}
/**
* Adds a new tag to the arena
* @param tagType {@linkcode ArenaTagType} the tag being added
* @param turnCount How many turns the tag lasts
* @param sourceMove {@linkcode MoveId} the move the tag came from, or `undefined` if not from a move
* @param sourceId The ID of the pokemon in play the tag came from (see {@linkcode BattleScene.getPokemonById})
* @param side {@linkcode ArenaTagSide} which side(s) the tag applies to
* @param quiet If a message should be queued on screen to announce the tag being added
* @param targetIndex The {@linkcode BattlerIndex} of the target pokemon
* @returns `false` if there already exists a tag of this type in the Arena
* Add a new {@linkcode ArenaTag} to the arena, triggering overlap effects on existing tags as applicable.
* @param tagType - The {@linkcode ArenaTagType} of the tag to add.
* @param turnCount - The number of turns the newly-added tag should last.
* @param sourceId - The {@linkcode Pokemon.id | PID} of the Pokemon creating the tag.
* @param sourceMove - The {@linkcode MoveId} of the move creating the tag, or `undefined` if not from a move.
* @param side - The {@linkcode ArenaTagSide}(s) to which the tag should apply; default `ArenaTagSide.BOTH`.
* @param quiet - Whether to suppress messages produced by tag addition; default `false`.
* @returns `true` if the tag was successfully added without overlapping.
// TODO: Do we need the return value here? literally nothing uses it
*/
addTag(
tagType: ArenaTagType,
@ -704,7 +704,6 @@ export class Arena {
sourceId: number,
side: ArenaTagSide = ArenaTagSide.BOTH,
quiet = false,
targetIndex?: BattlerIndex,
): boolean {
const existingTag = this.getTagOnSide(tagType, side);
if (existingTag) {
@ -719,7 +718,7 @@ export class Arena {
}
// creates a new tag object
const newTag = getArenaTag(tagType, turnCount || 0, sourceMove, sourceId, targetIndex, side);
const newTag = getArenaTag(tagType, turnCount || 0, sourceMove, sourceId, side);
if (newTag) {
newTag.onAdd(this, quiet);
this.tags.push(newTag);
@ -735,10 +734,21 @@ export class Arena {
}
/**
* Attempts to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides
* @param tagType The {@linkcode ArenaTagType} or {@linkcode ArenaTag} to get
* @returns either the {@linkcode ArenaTag}, or `undefined` if it isn't there
* Attempt to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides
* @param tagType The {@linkcode ArenaTagType} to retrieve
* @returns The existing {@linkcode ArenaTag}, or `undefined` if not present.
* @overload
*/
getTag(tagType: ArenaTagType): ArenaTag | undefined;
/**
* Attempt to get a tag from the Arena via {@linkcode getTagOnSide} that applies to both sides
* @param tagType The {@linkcode ArenaTag} to retrieve
* @returns The existing {@linkcode ArenaTag}, or `undefined` if not present.
* @overload
*/
getTag<T extends ArenaTag>(tagType: Constructor<T>): T | undefined;
getTag(tagType: ArenaTagType | Constructor<ArenaTag>): ArenaTag | undefined {
return this.getTagOnSide(tagType, ArenaTagSide.BOTH);
}

View File

@ -7,6 +7,7 @@ import i18next from "i18next";
import { NumberHolder } from "#app/utils/common";
import { PokemonPhase } from "./pokemon-phase";
import { globalScene } from "#app/global-scene";
import { ArenaTagType } from "#enums/arena-tag-type";
export class AttemptRunPhase extends PokemonPhase {
public readonly phaseName = "AttemptRunPhase";
@ -45,6 +46,9 @@ export class AttemptRunPhase extends PokemonPhase {
globalScene.clearEnemyHeldItemModifiers();
// clear all queued delayed attacks (e.g. from Future Sight)
globalScene.arena.removeTag(ArenaTagType.DELAYED_ATTACK);
// biome-ignore lint/complexity/noForEach: TODO
enemyField.forEach(enemyPokemon => {
enemyPokemon.hideInfo().then(() => enemyPokemon.destroy());

View File

@ -275,12 +275,13 @@ export class MoveEffectPhase extends PokemonPhase {
}
}
const move = this.move;
/**
* Does an effect from this move override other effects on this turn?
* e.g. Charging moves (Fly, etc.) on their first turn of use.
*/
const overridden = new BooleanHolder(false);
const move = this.move;
// Apply effects to override a move effect.
// Assuming single target here works as this is (currently)
@ -368,7 +369,7 @@ export class MoveEffectPhase extends PokemonPhase {
*/
private postAnimCallback(user: Pokemon, targets: Pokemon[]) {
// Add to the move history entry
if (this.firstHit) {
if (this.firstHit && this.useMode !== MoveUseMode.TRANSPARENT) {
user.pushMoveHistory(this.moveHistoryEntry);
applyExecutedMoveAbAttrs("ExecutedMoveAbAttr", user);
}

View File

@ -1,7 +1,6 @@
import { BattlerIndex } from "#enums/battler-index";
import { globalScene } from "#app/global-scene";
import { applyAbAttrs, applyPostMoveUsedAbAttrs, applyPreAttackAbAttrs } from "#app/data/abilities/apply-ab-attrs";
import type { DelayedAttackTag } from "#app/data/arena-tag";
import { CommonAnim } from "#enums/move-anims-common";
import { CenterOfAttentionTag } from "#app/data/battler-tags";
import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type";
@ -21,7 +20,6 @@ import Overrides from "#app/overrides";
import { BattlePhase } from "#app/phases/battle-phase";
import { enumValueToKey, NumberHolder } from "#app/utils/common";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { StatusEffect } from "#enums/status-effect";
@ -299,33 +297,6 @@ export class MovePhase extends BattlePhase {
// form changes happen even before we know that the move wll execute.
globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger);
const isDelayedAttack = move.hasAttr("DelayedAttackAttr");
if (isDelayedAttack) {
// Check the player side arena if future sight is active
const futureSightTags = globalScene.arena.findTags(t => t.tagType === ArenaTagType.FUTURE_SIGHT);
const doomDesireTags = globalScene.arena.findTags(t => t.tagType === ArenaTagType.DOOM_DESIRE);
let fail = false;
const currentTargetIndex = targets[0].getBattlerIndex();
for (const tag of futureSightTags) {
if ((tag as DelayedAttackTag).targetIndex === currentTargetIndex) {
fail = true;
break;
}
}
for (const tag of doomDesireTags) {
if ((tag as DelayedAttackTag).targetIndex === currentTargetIndex) {
fail = true;
break;
}
}
if (fail) {
this.showMoveText();
this.showFailedText();
this.end();
return;
}
}
let success = true;
// Check if there are any attributes that can interrupt the move, overriding the fail message.
for (const move of this.move.getMove().getAttrs("PreUseInterruptAttr")) {
@ -668,6 +639,9 @@ export class MovePhase extends BattlePhase {
}),
500,
);
// Moves with pre-use messages (Magnitude, Chilly Reception, Fickle Beam, etc.) always display their messages even on failure
// TODO: This assumes single target for message funcs - is this sustainable?
applyMoveAttrs("PreMoveMessageAttr", this.pokemon, this.pokemon.getOpponents(false)[0], this.move.getMove());
}

View File

@ -61,6 +61,7 @@ export class TurnEndPhase extends FieldPhase {
this.executeForAll(handlePokemon);
// TODO: This needs to be moved up before the `handlePokemon` call for Electrify, while also
globalScene.arena.lapseTags();
if (globalScene.arena.weather && !globalScene.arena.weather.lapse()) {

View File

@ -245,6 +245,7 @@ export async function initI18n(): Promise<void> {
"pokeball",
"pokedexUiHandler",
"pokemon",
"pokemonCategory",
"pokemonEvolutions",
"pokemonForm",
"pokemonInfo",

View File

@ -174,6 +174,7 @@ export default class PokedexPageUiHandler extends MessageUiHandler {
private pokemonCaughtHatchedContainer: Phaser.GameObjects.Container;
private pokemonCaughtCountText: Phaser.GameObjects.Text;
private pokemonFormText: Phaser.GameObjects.Text;
private pokemonCategoryText: Phaser.GameObjects.Text;
private pokemonHatchedIcon: Phaser.GameObjects.Sprite;
private pokemonHatchedCountText: Phaser.GameObjects.Text;
private pokemonShinyIcons: Phaser.GameObjects.Sprite[];
@ -409,6 +410,12 @@ export default class PokedexPageUiHandler extends MessageUiHandler {
this.pokemonFormText.setOrigin(0, 0);
this.starterSelectContainer.add(this.pokemonFormText);
this.pokemonCategoryText = addTextObject(100, 18, "Category", TextStyle.WINDOW_ALT, {
fontSize: "42px",
});
this.pokemonCategoryText.setOrigin(1, 0);
this.starterSelectContainer.add(this.pokemonCategoryText);
this.pokemonCaughtHatchedContainer = globalScene.add.container(2, 25);
this.pokemonCaughtHatchedContainer.setScale(0.5);
this.starterSelectContainer.add(this.pokemonCaughtHatchedContainer);
@ -2354,6 +2361,7 @@ export default class PokedexPageUiHandler extends MessageUiHandler {
this.pokemonCaughtHatchedContainer.setVisible(true);
this.pokemonCandyContainer.setVisible(false);
this.pokemonFormText.setVisible(false);
this.pokemonCategoryText.setVisible(false);
const defaultDexAttr = globalScene.gameData.getSpeciesDefaultDexAttr(species, true, true);
const props = globalScene.gameData.getSpeciesDexAttrProps(species, defaultDexAttr);
@ -2382,6 +2390,7 @@ export default class PokedexPageUiHandler extends MessageUiHandler {
this.pokemonCaughtHatchedContainer.setVisible(false);
this.pokemonCandyContainer.setVisible(false);
this.pokemonFormText.setVisible(false);
this.pokemonCategoryText.setVisible(false);
this.setSpeciesDetails(species!, {
// TODO: is this bang correct?
@ -2534,6 +2543,13 @@ export default class PokedexPageUiHandler extends MessageUiHandler {
this.pokemonNameText.setText(species ? "???" : "");
}
// Setting the category
if (isFormCaught) {
this.pokemonCategoryText.setText(species.category);
} else {
this.pokemonCategoryText.setText("");
}
// Setting tint of the sprite
if (isFormCaught) {
this.species.loadAssets(female!, formIndex, shiny, variant as Variant, true).then(() => {

View File

@ -1,11 +1,14 @@
import { AbilityId } from "#enums/ability-id";
import { RandomMoveAttr } from "#app/data/moves/move";
import { MoveResult } from "#enums/move-result";
import { getPokemonNameWithAffix } from "#app/messages";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { AbilityId } from "#app/enums/ability-id";
import { WeatherType } from "#enums/weather-type";
import GameManager from "#test/testUtils/gameManager";
import i18next from "i18next";
import Phaser from "phaser";
//import { TurnInitPhase } from "#app/phases/turn-init-phase";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Chilly Reception", () => {
let phaserGame: Phaser.Game;
@ -25,95 +28,121 @@ describe("Moves - Chilly Reception", () => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.moveset([MoveId.CHILLY_RECEPTION, MoveId.SNOWSCAPE])
.moveset([MoveId.CHILLY_RECEPTION, MoveId.SNOWSCAPE, MoveId.SPLASH, MoveId.METRONOME])
.enemyMoveset(MoveId.SPLASH)
.enemyAbility(AbilityId.BALL_FETCH)
.ability(AbilityId.BALL_FETCH);
});
it("should still change the weather if user can't switch out", async () => {
it("should display message before use, switch the user out and change the weather to snow", async () => {
await game.classicMode.startBattle([SpeciesId.SLOWKING, SpeciesId.MEOWTH]);
const [slowking, meowth] = game.scene.getPlayerParty();
game.move.select(MoveId.CHILLY_RECEPTION);
game.doSelectPartyPokemon(1);
await game.toEndOfTurn();
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
expect(game.scene.getPlayerPokemon()).toBe(meowth);
expect(slowking.isOnField()).toBe(false);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.textInterceptor.logs).toContain(
i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(slowking) }),
);
});
it("should still change weather if user can't switch out", async () => {
await game.classicMode.startBattle([SpeciesId.SLOWKING]);
game.move.select(MoveId.CHILLY_RECEPTION);
await game.toEndOfTurn();
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()?.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
});
it("should switch out even if it's snowing", async () => {
it("should still switch out even if weather cannot be changed", async () => {
await game.classicMode.startBattle([SpeciesId.SLOWKING, SpeciesId.MEOWTH]);
// first turn set up snow with snowscape, try chilly reception on second turn
expect(game.scene.arena.weather?.weatherType).not.toBe(WeatherType.SNOW);
const [slowking, meowth] = game.scene.getPlayerParty();
game.move.select(MoveId.SNOWSCAPE);
await game.phaseInterceptor.to("BerryPhase", false);
await game.toNextTurn();
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
await game.phaseInterceptor.to("TurnInitPhase", false);
game.move.select(MoveId.CHILLY_RECEPTION);
game.doSelectPartyPokemon(1);
// TODO: Uncomment lines once wimp out PR fixes force switches to not reset summon data immediately
// await game.phaseInterceptor.to("SwitchSummonPhase", false);
// expect(slowking.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
await game.toEndOfTurn();
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
expect(game.scene.getPlayerField()[0].species.speciesId).toBe(SpeciesId.MEOWTH);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()).toBe(meowth);
expect(slowking.isOnField()).toBe(false);
});
it("happy case - switch out and weather changes", async () => {
// Source: https://replay.pokemonshowdown.com/gen9ou-2367532550
it("should fail (while still displaying message) if neither weather change nor switch out succeeds", async () => {
await game.classicMode.startBattle([SpeciesId.SLOWKING]);
expect(game.scene.arena.weather?.weatherType).not.toBe(WeatherType.SNOW);
const slowking = game.scene.getPlayerPokemon()!;
game.move.select(MoveId.SNOWSCAPE);
await game.toNextTurn();
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
game.move.select(MoveId.CHILLY_RECEPTION);
game.doSelectPartyPokemon(1);
await game.toEndOfTurn();
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
expect(game.phaseInterceptor.log).not.toContain("SwitchSummonPhase");
expect(game.scene.getPlayerPokemon()).toBe(slowking);
expect(slowking.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(game.textInterceptor.logs).toContain(
i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(slowking) }),
);
});
it("should succeed without message if called indirectly", async () => {
vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(MoveId.CHILLY_RECEPTION);
await game.classicMode.startBattle([SpeciesId.SLOWKING, SpeciesId.MEOWTH]);
game.move.select(MoveId.CHILLY_RECEPTION);
game.doSelectPartyPokemon(1);
const [slowking, meowth] = game.scene.getPlayerParty();
game.move.select(MoveId.METRONOME);
game.doSelectPartyPokemon(1);
await game.toEndOfTurn();
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
expect(game.scene.getPlayerField()[0].species.speciesId).toBe(SpeciesId.MEOWTH);
expect(game.scene.getPlayerPokemon()).toBe(meowth);
expect(slowking.isOnField()).toBe(false);
expect(game.phaseInterceptor.log).toContain("SwitchSummonPhase");
expect(game.textInterceptor.logs).not.toContain(
i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(slowking) }),
);
});
// enemy uses another move and weather doesn't change
it("check case - enemy not selecting chilly reception doesn't change weather ", async () => {
game.override.battleStyle("single").enemyMoveset([MoveId.CHILLY_RECEPTION, MoveId.TACKLE]).moveset(MoveId.SPLASH);
// Bugcheck test for enemy AI bug
it("check case - enemy not selecting chilly reception doesn't change weather", async () => {
game.override.enemyMoveset([MoveId.CHILLY_RECEPTION, MoveId.TACKLE]);
await game.classicMode.startBattle([SpeciesId.SLOWKING, SpeciesId.MEOWTH]);
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.toEndOfTurn();
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(undefined);
});
it("enemy trainer - expected behavior ", async () => {
game.override
.battleStyle("single")
.startingWave(8)
.enemyMoveset(MoveId.CHILLY_RECEPTION)
.enemySpecies(SpeciesId.MAGIKARP)
.moveset([MoveId.SPLASH, MoveId.THUNDERBOLT]);
await game.classicMode.startBattle([SpeciesId.JOLTEON]);
const RIVAL_MAGIKARP1 = game.scene.getEnemyPokemon()?.id;
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
expect(game.scene.getEnemyPokemon()?.id !== RIVAL_MAGIKARP1);
await game.phaseInterceptor.to("TurnInitPhase", false);
game.move.select(MoveId.SPLASH);
// second chilly reception should still switch out
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
await game.phaseInterceptor.to("TurnInitPhase", false);
expect(game.scene.getEnemyPokemon()?.id === RIVAL_MAGIKARP1);
game.move.select(MoveId.THUNDERBOLT);
// enemy chilly recep move should fail: it's snowing and no option to switch out
// no crashing
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
await game.phaseInterceptor.to("TurnInitPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
game.move.select(MoveId.SPLASH);
await game.phaseInterceptor.to("BerryPhase", false);
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.SNOW);
expect(game.scene.arena.weather?.weatherType).toBeUndefined();
});
});

View File

@ -0,0 +1,326 @@
import { BattlerIndex } from "#enums/battler-index";
import { allMoves } from "#app/data/data-lists";
import { DelayedAttackTag } from "#app/data/arena-tag";
import { allAbilities } from "#app/data/data-lists";
import { RandomMoveAttr } from "#app/data/moves/move";
import { MoveResult } from "#enums/move-result";
import { getPokemonNameWithAffix } from "#app/messages";
import { AttackTypeBoosterModifier } from "#app/modifier/modifier";
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import GameManager from "#test/testUtils/gameManager";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { BattleType } from "#enums/battle-type";
describe("Moves - Delayed Attacks", () => {
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.NO_GUARD)
.battleStyle("single")
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.SPLASH);
});
/**
* Wait until a number of turns have passed.
* @param numTurns - Number of turns to pass.
* @returns: A Promise that resolves once the specified number of turns has elapsed.
*/
async function passTurns(numTurns: number): Promise<void> {
for (let i = 0; i < numTurns; i++) {
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
if (game.scene.currentBattle.double && game.scene.getPlayerField()[1]) {
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
}
await game.toNextTurn();
}
}
/**
* Expect that future sight is active with the specified number of attacks.
* @param numAttacks - The number of delayed attacks that should be queued; default `1`
*/
function expectFutureSightActive(numAttacks = 1) {
const tag = game.scene.arena.getTag(DelayedAttackTag)!;
expect(tag).toBeDefined();
expect(tag["delayedAttacks"]).toHaveLength(numAttacks);
}
it.each<{ name: string; move: MoveId }>([
{ name: "Future Sight", move: MoveId.FUTURE_SIGHT },
{ name: "Doom Desire", move: MoveId.DOOM_DESIRE },
])("$name should show message and strike 2 turns after use, ignoring player/enemy switches", async ({ move }) => {
game.override.battleType(BattleType.TRAINER)
await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]);
game.move.use(move);
await game.toNextTurn();
expectFutureSightActive();
game.doSwitchPokemon(1);
game.forceEnemyToSwitch();
await game.toNextTurn();
game.move.use(MoveId.SPLASH);
await game.toEndOfTurn();
const enemy = game.field.getEnemyPokemon();
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
expect(game.textInterceptor.logs).toContain(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy),
moveName: allMoves[move].name,
}),
);
});
it("should fail (preserving prior instances) when used against the same target", async () => {
await game.classicMode.startBattle([SpeciesId.BRONZONG]);
game.move.use(MoveId.FUTURE_SIGHT);
await game.toNextTurn();
expectFutureSightActive();
const bronzong = game.field.getPlayerPokemon();
expect(bronzong.getLastXMoves()[0].result).toBe(MoveResult.OTHER);
game.move.use(MoveId.FUTURE_SIGHT);
await game.toNextTurn();
expectFutureSightActive();
expect(bronzong.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should still be delayed when copied by other moves", async () => {
vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(MoveId.FUTURE_SIGHT);
await game.classicMode.startBattle([SpeciesId.BRONZONG]);
game.move.use(MoveId.METRONOME);
await game.toNextTurn();
const enemy = game.field.getEnemyPokemon();
expect(enemy.hp).toBe(enemy.getMaxHp());
expectFutureSightActive();
await passTurns(2);
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
});
it("should work when used against different targets in doubles", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]);
const [karp, feebas, enemy1, enemy2] = game.scene.getField();
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2);
await game.toEndOfTurn()
expectFutureSightActive(2);
expect(enemy1.hp).toBe(enemy1.getMaxHp());
expect(enemy2.hp).toBe(enemy2.getMaxHp());
expect(karp.getLastXMoves()[0].result).toBe(MoveResult.OTHER);
expect(feebas.getLastXMoves()[0].result).toBe(MoveResult.OTHER);
await passTurns(2);
expect(enemy1.hp).toBeLessThan(enemy1.getMaxHp());
expect(enemy2.hp).toBeLessThan(enemy2.getMaxHp());
});
it("should vanish silently if it would otherwise hit the user", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS, SpeciesId.MIENFOO]);
const [karp, feebas] = game.scene.getPlayerField();
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2);
// Karp / Feebas / Milotic
game.doSwitchPokemon(2);
await game.toNextTurn();
expectFutureSightActive(1);
// Milotic / Feebas // Karp
game.doSwitchPokemon(2);
// Feebas / Karp // Milotic
game.doSwitchPokemon(2);
await game.toNextTurn();
await passTurns(1);
expect(karp.hp).toBe(karp.getMaxHp());
expect(feebas.hp).toBe(feebas.getMaxHp());
expect(game.textInterceptor.logs).not.toContain(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(karp),
moveName: allMoves[MoveId.FUTURE_SIGHT].name,
}),
);
});
it("should redirect normally if target is fainted when attack is launched", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const [enemy1, enemy2] = game.scene.getEnemyField();
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2);
await game.killPokemon(enemy2);
await game.toNextTurn();
expect(enemy2.isFainted()).toBe(true);
expectFutureSightActive(1);
await passTurns(2);
expect(enemy1.hp).toBeLessThan(enemy1.getMaxHp());
expect(game.textInterceptor.logs).toContain(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy1),
moveName: allMoves[MoveId.FUTURE_SIGHT].name,
}),
);
});
it("should vanish silently if target is fainted when attack lands", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const [enemy1, enemy2] = game.scene.getEnemyField();
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2);
await game.toNextTurn();
expectFutureSightActive(1);
await passTurns(1);
game.move.use(MoveId.SPLASH);
await game.killPokemon(enemy2);
await game.toNextTurn();
expect(enemy1.hp).toBe(enemy1.getMaxHp());
expect(game.textInterceptor.logs).not.toContain(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy1),
moveName: allMoves[MoveId.FUTURE_SIGHT].name,
}),
);
});
// TODO: ArenaTags currently proc concurrently with battler tag removal in `TurnEndPhase`,
// meaning the queued `MoveEffectPhase` no longer has Electrify applied to it
it.todo("should consider type changes at moment of execution & ignore Lightning Rod redirection", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
// fake left enemy having lightning rod
const [enemy1, enemy2] = game.scene.getEnemyField();
vi.spyOn(enemy1, "getAbility").mockReturnValue(allAbilities[AbilityId.LIGHTNING_ROD]);
// helps with logging
vi.spyOn(enemy1, "getNameToRender").mockReturnValue("Karp 1");
vi.spyOn(enemy2, "getNameToRender").mockReturnValue("Karp 2");
game.move.use(MoveId.FUTURE_SIGHT, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2);
await game.toNextTurn();
expectFutureSightActive(1);
await passTurns(1);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.changeMoveset(enemy1, MoveId.ELECTRIFY);
await game.move.forceEnemyMove(MoveId.ELECTRIFY, BattlerIndex.PLAYER);
await game.phaseInterceptor.to("TurnEndPhase");
await game.phaseInterceptor.to("MoveEffectPhase", false);
// Wait until all normal attacks have triggered, then check pending MEP
const karp = game.field.getPlayerPokemon();
const typeMock = vi.spyOn(karp, "getMoveType");
await game.toNextTurn();
expect(enemy1.hp).toBe(enemy1.getMaxHp());
expect(enemy2.hp).toBeLessThan(enemy2.getMaxHp());
expect(game.textInterceptor.logs).toContain(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy2),
moveName: allMoves[MoveId.FUTURE_SIGHT].name,
}),
);
expect(typeMock).toHaveLastReturnedWith(PokemonType.ELECTRIC);
});
// TODO: Enable once code is added to MEP to do this
it.todo("should not apply the user's abilities when dealing damage if the user is inactive", async () => {
game.override.ability(AbilityId.NORMALIZE).enemySpecies(SpeciesId.LUNALA);
await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]);
game.move.use(MoveId.DOOM_DESIRE);
await game.toNextTurn();
expectFutureSightActive();
await passTurns(1);
game.doSwitchPokemon(1);
const karp = game.field.getPlayerPokemon();
const typeMock = vi.spyOn(karp, "getMoveType");
await game.toNextTurn();
const enemy = game.field.getEnemyPokemon();
expect(enemy.hp).toBe(enemy.getMaxHp());
expect(game.textInterceptor.logs).toContain(
i18next.t("moveTriggers:tookMoveAttack", {
pokemonName: getPokemonNameWithAffix(enemy),
moveName: allMoves[MoveId.FUTURE_SIGHT].name,
}),
);
expect(typeMock).toHaveLastReturnedWith(PokemonType.NORMAL);
});
it.todo("should not apply the user's held items when dealing damage if the user is inactive", async () => {
game.override.startingHeldItems([{ name: "ATTACK_TYPE_BOOSTER", count: 99, type: PokemonType.STEEL }]);
await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]);
game.move.use(MoveId.DOOM_DESIRE);
await game.toNextTurn();
expectFutureSightActive();
await passTurns(1);
game.doSwitchPokemon(1);
const powerMock = vi.spyOn(allMoves[MoveId.DOOM_DESIRE], "calculateBattlePower");
const typeBoostSpy = vi.spyOn(AttackTypeBoosterModifier.prototype, "apply");
await game.toNextTurn();
expect(powerMock).toHaveLastReturnedWith(120);
expect(typeBoostSpy).not.toHaveBeenCalled();
});
});

View File

@ -1,45 +0,0 @@
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import GameManager from "#test/testUtils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Future Sight", () => {
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
.startingLevel(50)
.moveset([MoveId.FUTURE_SIGHT, MoveId.SPLASH])
.battleStyle("single")
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.SPLASH);
});
it("hits 2 turns after use, ignores user switch out", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]);
game.move.select(MoveId.FUTURE_SIGHT);
await game.toNextTurn();
game.doSwitchPokemon(1);
await game.toNextTurn();
game.move.select(MoveId.SPLASH);
await game.toNextTurn();
expect(game.scene.getEnemyPokemon()!.isFullHp()).toBe(false);
});
});