Compare commits

...

20 Commits

Author SHA1 Message Date
Bertie690
85ab4e9d47
Merge b57788b908 into 6c0253ada4 2025-08-11 14:49:56 +00:00
Bertie690
b57788b908 Merge remote-tracking branch 'upstream/beta' into todo-test-enable 2025-08-11 10:47:11 -04:00
Jimmybald1
6c0253ada4
[Misc] Expanded Daily Run custom seeds (#6248)
* Modify custom starters and added boss, biome and luck custom seed overrides

* Added form index to boss custom seed

* Fix circular dependency in daily-run.ts

* Review for PR 6248

- Use early returns

- Update TSDocs

- Use `getEnumValues` instead of `Object.values` for `enum`s

- Add console logging for invalid seeds

---------

Co-authored-by: Jimmybald1 <147992650+IBBCalc@users.noreply.github.com>
Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
2025-08-10 23:43:31 -04:00
Bertie690
412feb07ca Moved function out of file to avoid circ imports 2025-08-06 21:05:39 -04:00
Bertie690
db718f683b fixed buggg 2025-08-06 20:59:41 -04:00
Bertie690
56ff84aa9d Ran Biome 2025-08-06 20:35:17 -04:00
Bertie690
cfbce175db Fixed instruct interaction with Encore 2025-08-06 20:33:31 -04:00
Bertie690
7d8f53e64e added more test 2025-08-06 18:57:47 -04:00
Bertie690
6cfb26c528 Moved methods around to be in sequential order 2025-08-06 18:43:24 -04:00
Bertie690
b2d10b7006 Moved reflection check out of MEP and into ability side logic 2025-08-06 18:29:12 -04:00
Bertie690
cbc6f6b89e jjjjjjhuoui 2025-08-05 23:59:31 -04:00
Bertie690
510d683cc1 fixed test setting incorrect pp values 2025-08-05 23:05:56 -04:00
Bertie690
502d6d9e12 xxxxxx 2025-08-05 22:33:19 -04:00
Bertie690
8b4951ed63 fixed tpoy 2025-08-05 22:05:41 -04:00
Bertie690
ba6885d289 ddd 2025-08-05 22:02:30 -04:00
Bertie690
ce8491f4a5 rrr 2025-08-05 21:41:35 -04:00
Bertie690
5653ec83be Fixed minor bugs in the files
encore was basing targets off of the PRIOR move, not the encored one. mb
2025-08-05 21:19:22 -04:00
Bertie690
93c3422f91 Fixed syntax errors 2025-08-05 20:56:13 -04:00
Bertie690
664bf555bd Fixed Encore interactions with Magic Bounce, Magic Coat, etc etc 2025-08-05 20:52:18 -04:00
Bertie690
69157f07bc Condensed magic bounce tests 2025-08-05 19:32:53 -04:00
20 changed files with 1322 additions and 1252 deletions

View File

@ -1,6 +1,8 @@
/* biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */
import type { BattleScene } from "#app/battle-scene";
import type { SpeciesFormChangeRevertWeatherFormTrigger } from "#data/form-change-triggers";
import type { MoveEffectPhase } from "#phases/move-effect-phase";
import type { MoveEndPhase } from "#phases/move-end-phase";
/* biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */
import { applyAbAttrs } from "#abilities/apply-ab-attrs";
@ -51,7 +53,8 @@ import { BerryModifierType } from "#modifiers/modifier-type";
import { applyMoveAttrs } from "#moves/apply-attrs";
import { noAbilityTypeOverrideMoves } from "#moves/invalid-moves";
import type { Move } from "#moves/move";
import type { PokemonMove } from "#moves/pokemon-move";
import { getMoveTargets } from "#moves/move-utils";
import { PokemonMove } from "#moves/pokemon-move";
import type { StatStageChangePhase } from "#phases/stat-stage-change-phase";
import type {
AbAttrCondition,
@ -5788,12 +5791,21 @@ export class InfiltratorAbAttr extends AbAttr {
/**
* Attribute implementing the effects of {@link https://bulbapedia.bulbagarden.net/wiki/Magic_Bounce_(ability) | Magic Bounce}.
* Allows the source to bounce back {@linkcode MoveFlags.REFLECTABLE | Reflectable}
* moves as if the user had used {@linkcode MoveId.MAGIC_COAT | Magic Coat}.
* @sealed
* @todo Make reflection a part of this ability's effects
* moves as if the user had used {@linkcode MoveId.MAGIC_COAT | Magic Coat}.
* The calling {@linkcode MoveEffectPhase} will "skip" targets with a reflection effect active,
* showing the flyout and queueing the reaction during the move's {@linkcode MoveEndPhase}.
*/
export class ReflectStatusMoveAbAttr extends AbAttr {
private declare readonly _: never;
export class ReflectStatusMoveAbAttr extends PreDefendAbAttr {
override apply({ pokemon, opponent, move }: AugmentMoveInteractionAbAttrParams): void {
const newTargets = move.isMultiTarget() ? getMoveTargets(pokemon, move.id).targets : [opponent.getBattlerIndex()];
globalScene.phaseManager.unshiftNew(
"MovePhase",
pokemon,
newTargets,
new PokemonMove(move.id),
MoveUseMode.REFLECTED,
);
}
}
// TODO: Make these ability attributes be flags instead of dummy attributes
@ -7250,10 +7262,7 @@ export function initAbilities() {
.attr(PostIntimidateStatStageChangeAbAttr, [ Stat.SPD ], 1),
new Ability(AbilityId.MAGIC_BOUNCE, 5)
.attr(ReflectStatusMoveAbAttr)
.ignorable()
// Interactions with stomping tantrum, instruct, encore, and probably other moves that
// rely on move history
.edgeCase(),
.ignorable(),
new Ability(AbilityId.SAP_SIPPER, 5)
.attr(TypeImmunityStatStageChangeAbAttr, PokemonType.GRASS, Stat.ATK, 1)
.ignorable(),
@ -7287,7 +7296,7 @@ export function initAbilities() {
.attr(PostSummonMessageAbAttr, (pokemon: Pokemon) => i18next.t("abilityTriggers:postSummonTeravolt", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }))
.attr(MoveAbilityBypassAbAttr),
new Ability(AbilityId.AROMA_VEIL, 6)
.attr(UserFieldBattlerTagImmunityAbAttr, [ BattlerTagType.INFATUATED, BattlerTagType.TAUNT, BattlerTagType.DISABLED, BattlerTagType.TORMENT, BattlerTagType.HEAL_BLOCK ])
.attr(UserFieldBattlerTagImmunityAbAttr, [ BattlerTagType.INFATUATED, BattlerTagType.TAUNT, BattlerTagType.DISABLED, BattlerTagType.TORMENT, BattlerTagType.HEAL_BLOCK, BattlerTagType.ENCORE ])
.ignorable(),
new Ability(AbilityId.FLOWER_VEIL, 6)
.attr(ConditionalUserFieldStatusEffectImmunityAbAttr, (target: Pokemon, source: Pokemon | null) => {
@ -7351,7 +7360,7 @@ export function initAbilities() {
new Ability(AbilityId.GOOEY, 6)
.attr(PostDefendStatStageChangeAbAttr, (_target, _user, move) => move.hasFlag(MoveFlags.MAKES_CONTACT), Stat.SPD, -1, false),
new Ability(AbilityId.AERILATE, 6)
.attr(MoveTypeChangeAbAttr, PokemonType.FLYING, 1.2, (_user, _target, move) => move.type === PokemonType.NORMAL),
.attr(MoveTypeChangeAbAttr, PokemonType.FLYING, 1.2, (_user, _target, move) => move.type === PokemonType.NORMAL),
new Ability(AbilityId.PARENTAL_BOND, 6)
.attr(AddSecondStrikeAbAttr, 0.25),
new Ability(AbilityId.DARK_AURA, 6)

View File

@ -28,6 +28,8 @@ import type { Pokemon } from "#field/pokemon";
import { applyMoveAttrs } from "#moves/apply-attrs";
import { invalidEncoreMoves } from "#moves/invalid-moves";
import type { Move } from "#moves/move";
import { getMoveTargets } from "#moves/move-utils";
import { PokemonMove } from "#moves/pokemon-move";
import type { MoveEffectPhase } from "#phases/move-effect-phase";
import type { MovePhase } from "#phases/move-phase";
import type { StatStageChangeCallback } from "#phases/stat-stage-change-phase";
@ -174,6 +176,7 @@ export class BattlerTag implements BaseBattlerTag {
return "";
}
// TODO: Make this a getter
isSourceLinked(): boolean {
return false;
}
@ -1238,13 +1241,16 @@ export class FrenzyTag extends SerializableBattlerTag {
*/
export class EncoreTag extends MoveRestrictionBattlerTag {
public override readonly tagType = BattlerTagType.ENCORE;
/** The ID of the move the user is locked into using */
/** The {@linkcode MoveID} the tag holder is locked into */
public moveId: MoveId;
constructor(sourceId: number) {
// Encore ends at the end of the 3rd turn it procs.
// If used on turn X when faster, it ends at the end of turn X+2.
// If used on turn X when slower, it ends at the end of turn X+3.
super(
BattlerTagType.ENCORE,
[BattlerTagLapseType.CUSTOM, BattlerTagLapseType.AFTER_MOVE],
[BattlerTagLapseType.AFTER_MOVE, BattlerTagLapseType.TURN_END],
3,
MoveId.ENCORE,
sourceId,
@ -1266,6 +1272,14 @@ export class EncoreTag extends MoveRestrictionBattlerTag {
return false;
}
if (!pokemon.getMoveset().some(m => m.moveId === lastMove.move && !m.isOutOfPp())) {
return false;
}
if (pokemon.getTag(BattlerTagType.SHELL_TRAP)) {
return false;
}
this.moveId = lastMove.move;
return true;
@ -1278,35 +1292,57 @@ export class EncoreTag extends MoveRestrictionBattlerTag {
}),
);
const movePhase = globalScene.phaseManager.findPhase(m => m.is("MovePhase") && m.pokemon === pokemon);
if (movePhase) {
const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId);
if (movesetMove) {
const lastMove = pokemon.getLastXMoves(1)[0];
globalScene.phaseManager.tryReplacePhase(
m => m.is("MovePhase") && m.pokemon === pokemon,
globalScene.phaseManager.create(
"MovePhase",
pokemon,
lastMove.targets ?? [],
movesetMove,
MoveUseMode.NORMAL,
),
);
}
// If the target has not moved yet,
// replace their upcoming move with the encored move against randomized targets
const movePhase = globalScene.phaseManager.findPhase(
(m): m is MovePhase => m.is("MovePhase") && m.pokemon === pokemon,
);
if (!movePhase) {
return;
}
// Use the prior move in the moveset.
// Bang is justified as `canAdd` returns false if not found
const movesetMove = pokemon.getMoveset().find(m => m.moveId === this.moveId)!;
const moveTargets = getMoveTargets(pokemon, this.moveId);
// Spread moves and ones with only 1 valid target will use their normal targeting.
// If not, target a random enemy in our target list
const targets =
moveTargets.multiple || moveTargets.targets.length === 1
? moveTargets.targets
: [moveTargets.targets[pokemon.randBattleSeedInt(moveTargets.targets.length)]];
globalScene.phaseManager.tryReplacePhase(
m => m.is("MovePhase") && m.pokemon === pokemon,
globalScene.phaseManager.create(
"MovePhase",
pokemon,
targets,
movesetMove,
movePhase.useMode,
movePhase.isForcedLast(),
),
);
}
/**
* If the encored move has run out of PP, Encore ends early. Otherwise, Encore lapses based on the AFTER_MOVE battler tag lapse type.
* @returns `true` to persist | `false` to end and be removed
* If the encored move has run out of PP or the tag's turn count has elapsed,
* Encore ends at the END of the turn.
* Otherwise, Encore's duration reduces when the target attempts to use a move.
* @returns Whether the tag should remain active.
*/
override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
if (lapseType === BattlerTagLapseType.CUSTOM) {
const encoredMove = pokemon.getMoveset().find(m => m.moveId === this.moveId);
return !isNullOrUndefined(encoredMove) && encoredMove.getPpRatio() > 0;
if (lapseType === BattlerTagLapseType.AFTER_MOVE) {
this.turnCount--;
return true;
}
return super.lapse(pokemon, lapseType);
const encoredMove = pokemon.getMoveset().find(m => m.moveId === this.moveId);
if (isNullOrUndefined(encoredMove) || encoredMove.isOutOfPp()) {
return false;
}
return this.turnCount > 0;
}
/**
@ -1489,12 +1525,8 @@ export class MinimizeTag extends SerializableBattlerTag {
export class DrowsyTag extends SerializableBattlerTag {
public override readonly tagType = BattlerTagType.DROWSY;
constructor() {
super(BattlerTagType.DROWSY, BattlerTagLapseType.TURN_END, 2, MoveId.YAWN);
}
canAdd(pokemon: Pokemon): boolean {
return globalScene.arena.terrain?.terrainType !== TerrainType.ELECTRIC || !pokemon.isGrounded();
constructor(sourceId: number) {
super(BattlerTagType.DROWSY, BattlerTagLapseType.TURN_END, 2, MoveId.YAWN, sourceId);
}
onAdd(pokemon: Pokemon): void {
@ -1509,6 +1541,7 @@ export class DrowsyTag extends SerializableBattlerTag {
lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean {
if (!super.lapse(pokemon, lapseType)) {
// TODO: Safeguard should not prevent yawn from setting sleep after tag use
pokemon.trySetStatus(StatusEffect.SLEEP, true);
return false;
}
@ -3632,6 +3665,23 @@ export class MagicCoatTag extends BattlerTag {
}),
);
}
/**
* Apply the tag to reflect a move.
* @param pokemon - The {@linkcode Pokemon} to whom this tag belongs
* @param opponent - The {@linkcode Pokemon} having originally used the move
* @param move - The {@linkcode Move} being used
*/
public apply(pokemon: Pokemon, opponent: Pokemon, move: Move): void {
const newTargets = move.isMultiTarget() ? getMoveTargets(pokemon, move.id).targets : [opponent.getBattlerIndex()];
globalScene.phaseManager.unshiftNew(
"MovePhase",
pokemon,
newTargets,
new PokemonMove(move.id),
MoveUseMode.REFLECTED,
);
}
}
/**
@ -3679,7 +3729,7 @@ export function getBattlerTag(
case BattlerTagType.AQUA_RING:
return new AquaRingTag();
case BattlerTagType.DROWSY:
return new DrowsyTag();
return new DrowsyTag(sourceId);
case BattlerTagType.TRAPPED:
return new TrappedTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId);
case BattlerTagType.NO_RETREAT:

View File

@ -5,10 +5,9 @@ import type { PokemonSpeciesForm } from "#data/pokemon-species";
import { PokemonSpecies } from "#data/pokemon-species";
import { BiomeId } from "#enums/biome-id";
import { PartyMemberStrength } from "#enums/party-member-strength";
import type { SpeciesId } from "#enums/species-id";
import { PlayerPokemon } from "#field/pokemon";
import { SpeciesId } from "#enums/species-id";
import type { Starter } from "#ui/starter-select-ui-handler";
import { randSeedGauss, randSeedInt, randSeedItem } from "#utils/common";
import { isNullOrUndefined, randSeedGauss, randSeedInt, randSeedItem } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils";
@ -32,15 +31,9 @@ export function getDailyRunStarters(seed: string): Starter[] {
() => {
const startingLevel = globalScene.gameMode.getStartingLevel();
if (/\d{18}$/.test(seed)) {
for (let s = 0; s < 3; s++) {
const offset = 6 + s * 6;
const starterSpeciesForm = getPokemonSpeciesForm(
Number.parseInt(seed.slice(offset, offset + 4)) as SpeciesId,
Number.parseInt(seed.slice(offset + 4, offset + 6)),
);
starters.push(getDailyRunStarter(starterSpeciesForm, startingLevel));
}
const eventStarters = getDailyEventSeedStarters(seed);
if (!isNullOrUndefined(eventStarters)) {
starters.push(...eventStarters);
return;
}
@ -72,18 +65,7 @@ function getDailyRunStarter(starterSpeciesForm: PokemonSpeciesForm, startingLeve
const starterSpecies =
starterSpeciesForm instanceof PokemonSpecies ? starterSpeciesForm : getPokemonSpecies(starterSpeciesForm.speciesId);
const formIndex = starterSpeciesForm instanceof PokemonSpecies ? undefined : starterSpeciesForm.formIndex;
const pokemon = new PlayerPokemon(
starterSpecies,
startingLevel,
undefined,
formIndex,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
);
const pokemon = globalScene.addPlayerPokemon(starterSpecies, startingLevel, undefined, formIndex);
const starter: Starter = {
species: starterSpecies,
dexAttr: pokemon.getDexAttr(),
@ -145,6 +127,11 @@ const dailyBiomeWeights: BiomeWeights = {
};
export function getDailyStartingBiome(): BiomeId {
const eventBiome = getDailyEventSeedBiome(globalScene.seed);
if (!isNullOrUndefined(eventBiome)) {
return eventBiome;
}
const biomes = getEnumValues(BiomeId).filter(b => b !== BiomeId.TOWN && b !== BiomeId.END);
let totalWeight = 0;
@ -169,3 +156,126 @@ export function getDailyStartingBiome(): BiomeId {
// TODO: should this use `randSeedItem`?
return biomes[randSeedInt(biomes.length)];
}
/**
* If this is Daily Mode and the seed is longer than a default seed
* then it has been modified and could contain a custom event seed. \
* Default seeds are always exactly 24 characters.
* @returns `true` if it is a Daily Event Seed.
*/
export function isDailyEventSeed(seed: string): boolean {
return globalScene.gameMode.isDaily && seed.length > 24;
}
/**
* Expects the seed to contain `/starters\d{18}/`
* where the digits alternate between 4 digits for the species ID and 2 digits for the form index
* (left padded with `0`s as necessary).
* @returns An array of {@linkcode Starter}s, or `null` if no valid match.
*/
export function getDailyEventSeedStarters(seed: string): Starter[] | null {
if (!isDailyEventSeed(seed)) {
return null;
}
const starters: Starter[] = [];
const match = /starters(\d{4})(\d{2})(\d{4})(\d{2})(\d{4})(\d{2})/g.exec(seed);
if (!match || match.length !== 7) {
return null;
}
for (let i = 1; i < match.length; i += 2) {
const speciesId = Number.parseInt(match[i]) as SpeciesId;
const formIndex = Number.parseInt(match[i + 1]);
if (!getEnumValues(SpeciesId).includes(speciesId)) {
console.warn("Invalid species ID used for custom daily run seed starter:", speciesId);
return null;
}
const starterForm = getPokemonSpeciesForm(speciesId, formIndex);
const startingLevel = globalScene.gameMode.getStartingLevel();
const starter = getDailyRunStarter(starterForm, startingLevel);
starters.push(starter);
}
return starters;
}
/**
* Expects the seed to contain `/boss\d{4}\d{2}/`
* where the first 4 digits are the species ID and the next 2 digits are the form index
* (left padded with `0`s as necessary).
* @returns A {@linkcode PokemonSpeciesForm} to be used for the boss, or `null` if no valid match.
*/
export function getDailyEventSeedBoss(seed: string): PokemonSpeciesForm | null {
if (!isDailyEventSeed(seed)) {
return null;
}
const match = /boss(\d{4})(\d{2})/g.exec(seed);
if (!match || match.length !== 3) {
return null;
}
const speciesId = Number.parseInt(match[1]) as SpeciesId;
const formIndex = Number.parseInt(match[2]);
if (!getEnumValues(SpeciesId).includes(speciesId)) {
console.warn("Invalid species ID used for custom daily run seed boss:", speciesId);
return null;
}
const starterForm = getPokemonSpeciesForm(speciesId, formIndex);
return starterForm;
}
/**
* Expects the seed to contain `/biome\d{2}/` where the 2 digits are a biome ID (left padded with `0` if necessary).
* @returns The biome to use or `null` if no valid match.
*/
export function getDailyEventSeedBiome(seed: string): BiomeId | null {
if (!isDailyEventSeed(seed)) {
return null;
}
const match = /biome(\d{2})/g.exec(seed);
if (!match || match.length !== 2) {
return null;
}
const startingBiome = Number.parseInt(match[1]) as BiomeId;
if (!getEnumValues(BiomeId).includes(startingBiome)) {
console.warn("Invalid biome ID used for custom daily run seed:", startingBiome);
return null;
}
return startingBiome;
}
/**
* Expects the seed to contain `/luck\d{2}/` where the 2 digits are a number between `0` and `14`
* (left padded with `0` if necessary).
* @returns The custom luck value or `null` if no valid match.
*/
export function getDailyEventSeedLuck(seed: string): number | null {
if (!isDailyEventSeed(seed)) {
return null;
}
const match = /luck(\d{2})/g.exec(seed);
if (!match || match.length !== 2) {
return null;
}
const luck = Number.parseInt(match[1]);
if (luck < 0 || luck > 14) {
console.warn("Invalid luck value used for custom daily run seed:", luck);
return null;
}
return luck;
}

View File

@ -280,3 +280,68 @@ export const invalidEncoreMoves: ReadonlySet<MoveId> = new Set([
MoveId.SLEEP_TALK,
MoveId.ENCORE,
]);
export const invalidInstructMoves: ReadonlySet<MoveId> = new Set([
// Locking/Continually Executed moves
MoveId.OUTRAGE,
MoveId.RAGING_FURY,
MoveId.ROLLOUT,
MoveId.PETAL_DANCE,
MoveId.THRASH,
MoveId.ICE_BALL,
MoveId.UPROAR,
// Multi-turn Moves
MoveId.BIDE,
MoveId.SHELL_TRAP,
MoveId.BEAK_BLAST,
MoveId.FOCUS_PUNCH,
// "First Turn Only" moves
MoveId.FAKE_OUT,
MoveId.FIRST_IMPRESSION,
MoveId.MAT_BLOCK,
// Moves with a recharge turn
MoveId.HYPER_BEAM,
MoveId.ETERNABEAM,
MoveId.FRENZY_PLANT,
MoveId.BLAST_BURN,
MoveId.HYDRO_CANNON,
MoveId.GIGA_IMPACT,
MoveId.PRISMATIC_LASER,
MoveId.ROAR_OF_TIME,
MoveId.ROCK_WRECKER,
MoveId.METEOR_ASSAULT,
// Charging & 2-turn moves
MoveId.DIG,
MoveId.FLY,
MoveId.BOUNCE,
MoveId.SHADOW_FORCE,
MoveId.PHANTOM_FORCE,
MoveId.DIVE,
MoveId.ELECTRO_SHOT,
MoveId.ICE_BURN,
MoveId.GEOMANCY,
MoveId.FREEZE_SHOCK,
MoveId.SKY_DROP,
MoveId.SKY_ATTACK,
MoveId.SKULL_BASH,
MoveId.SOLAR_BEAM,
MoveId.SOLAR_BLADE,
MoveId.METEOR_BEAM,
// Copying/Move-Calling moves
MoveId.ASSIST,
MoveId.COPYCAT,
MoveId.ME_FIRST,
MoveId.METRONOME,
MoveId.MIRROR_MOVE,
MoveId.NATURE_POWER,
MoveId.SLEEP_TALK,
MoveId.SNATCH,
MoveId.INSTRUCT,
// Misc moves
MoveId.KINGS_SHIELD,
MoveId.SKETCH,
MoveId.TRANSFORM,
MoveId.MIMIC,
MoveId.STRUGGLE,
// TODO: Add Max/G-Max/Z-Move blockage if or when they are implemented
]);

View File

@ -11,6 +11,7 @@ import { WeakenMoveTypeTag } from "#data/arena-tag";
import { MoveChargeAnim } from "#data/battle-anims";
import {
CommandedTag,
DrowsyTag,
EncoreTag,
GulpMissileTag,
HelpingHandTag,
@ -77,7 +78,7 @@ import {
PreserveBerryModifier,
} from "#modifiers/modifier";
import { applyMoveAttrs } from "#moves/apply-attrs";
import { invalidAssistMoves, invalidCopycatMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves";
import { invalidAssistMoves, invalidCopycatMoves, invalidInstructMoves, invalidMetronomeMoves, invalidMirrorMoveMoves, invalidSketchMoves, invalidSleepTalkMoves } from "#moves/invalid-moves";
import { frenzyMissFunc, getMoveTargets } from "#moves/move-utils";
import { PokemonMove } from "#moves/pokemon-move";
import { MoveEndPhase } from "#phases/move-end-phase";
@ -673,20 +674,9 @@ export abstract class Move implements Localizable {
return true;
}
break;
case MoveFlags.REFLECTABLE:
// If the target is not semi-invulnerable and either has magic coat active or an unignored magic bounce ability
if (
target?.getTag(SemiInvulnerableTag) ||
!(target?.getTag(BattlerTagType.MAGIC_COAT) ||
(!this.doesFlagEffectApply({ flag: MoveFlags.IGNORE_ABILITIES, user, target }) &&
target?.hasAbilityWithAttr("ReflectStatusMoveAbAttr")))
) {
return false;
}
break;
}
return !!(this.flags & flag);
return this.hasFlag(flag)
}
/**
@ -5689,6 +5679,34 @@ export class AddBattlerTagAttr extends MoveEffectAttr {
}
}
/**
* Attribute to implement {@linkcode MoveId.YAWN}.
* Yawn adds a BattlerTag to its target that puts them to sleep at the end
* of the next turn, retaining many of the same checks as normal status setting moves.
*/
export class YawnAttr extends AddBattlerTagAttr {
constructor() {
super(BattlerTagType.DROWSY, false, true)
}
getCondition(): MoveConditionFunc {
return (user, target, move) => {
if (!super.getCondition()!(user, target, move)) {
return false;
}
// Statused opponents or ones with safeguard active use a generic failure message
if (target.status || target.isSafeguarded(user)) {
return false;
}
// TODO: This does not display the cause of the "but it failed" message,
// but fixing it would require a rework of the move failure system
return target.canSetStatus(StatusEffect.SLEEP, true, false, user)
}
}
}
/**
* Adds a {@link https://bulbapedia.bulbagarden.net/wiki/Seeding | Seeding} effect to the target
* as seen with Leech Seed and Sappy Seed.
@ -5916,8 +5934,8 @@ export class ProtectAttr extends AddBattlerTagAttr {
for (const turnMove of user.getLastXMoves(-1).slice()) {
if (
// Quick & Wide guard increment the Protect counter without using it for fail chance
!(allMoves[turnMove.move].hasAttr("ProtectAttr") ||
[MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) ||
!(allMoves[turnMove.move].hasAttr("ProtectAttr") ||
[MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) ||
turnMove.result !== MoveResult.SUCCESS
) {
break;
@ -7166,7 +7184,6 @@ export class RepeatMoveAttr extends MoveEffectAttr {
// bangs are justified as Instruct fails if no prior move or moveset move exists
// TODO: How does instruct work when copying a move called via Copycat that the user itself knows?
const lastMove = target.getLastNonVirtualMove()!;
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove?.move)!
// If the last move used can hit more than one target or has variable targets,
// re-compute the targets for the attack (mainly for alternating double/single battles)
@ -7190,12 +7207,18 @@ export class RepeatMoveAttr extends MoveEffectAttr {
}
}
// If the target is currently affected by Encore, increase its duration by 1 (to offset decrease during move use)
const targetEncore = target.getTag(BattlerTagType.ENCORE) as EncoreTag | undefined;
if (targetEncore) {
targetEncore.turnCount++
}
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:instructingMove", {
userPokemonName: getPokemonNameWithAffix(user),
targetPokemonName: getPokemonNameWithAffix(target)
}));
target.turnData.extraTurns++;
globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MovePhase", target, moveTargets, movesetMove, MoveUseMode.NORMAL);
globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MovePhase", target, moveTargets, this.movesetMove, MoveUseMode.NORMAL);
return true;
}
@ -7204,77 +7227,13 @@ export class RepeatMoveAttr extends MoveEffectAttr {
// TODO: Check instruct behavior with struggle - ignore, fail or success
const lastMove = target.getLastNonVirtualMove();
const movesetMove = target.getMoveset().find(m => m.moveId === lastMove?.move);
const uninstructableMoves = [
// Locking/Continually Executed moves
MoveId.OUTRAGE,
MoveId.RAGING_FURY,
MoveId.ROLLOUT,
MoveId.PETAL_DANCE,
MoveId.THRASH,
MoveId.ICE_BALL,
MoveId.UPROAR,
// Multi-turn Moves
MoveId.BIDE,
MoveId.SHELL_TRAP,
MoveId.BEAK_BLAST,
MoveId.FOCUS_PUNCH,
// "First Turn Only" moves
MoveId.FAKE_OUT,
MoveId.FIRST_IMPRESSION,
MoveId.MAT_BLOCK,
// Moves with a recharge turn
MoveId.HYPER_BEAM,
MoveId.ETERNABEAM,
MoveId.FRENZY_PLANT,
MoveId.BLAST_BURN,
MoveId.HYDRO_CANNON,
MoveId.GIGA_IMPACT,
MoveId.PRISMATIC_LASER,
MoveId.ROAR_OF_TIME,
MoveId.ROCK_WRECKER,
MoveId.METEOR_ASSAULT,
// Charging & 2-turn moves
MoveId.DIG,
MoveId.FLY,
MoveId.BOUNCE,
MoveId.SHADOW_FORCE,
MoveId.PHANTOM_FORCE,
MoveId.DIVE,
MoveId.ELECTRO_SHOT,
MoveId.ICE_BURN,
MoveId.GEOMANCY,
MoveId.FREEZE_SHOCK,
MoveId.SKY_DROP,
MoveId.SKY_ATTACK,
MoveId.SKULL_BASH,
MoveId.SOLAR_BEAM,
MoveId.SOLAR_BLADE,
MoveId.METEOR_BEAM,
// Copying/Move-Calling moves
MoveId.ASSIST,
MoveId.COPYCAT,
MoveId.ME_FIRST,
MoveId.METRONOME,
MoveId.MIRROR_MOVE,
MoveId.NATURE_POWER,
MoveId.SLEEP_TALK,
MoveId.SNATCH,
MoveId.INSTRUCT,
// Misc moves
MoveId.KINGS_SHIELD,
MoveId.SKETCH,
MoveId.TRANSFORM,
MoveId.MIMIC,
MoveId.STRUGGLE,
// TODO: Add Max/G-Max/Z-Move blockage if or when they are implemented
];
if (!lastMove?.move // no move to instruct
if (
!lastMove?.move // no move to instruct
|| !movesetMove // called move not in target's moveset (forgetting the move, etc.)
|| movesetMove.ppUsed === movesetMove.getMovePp() // move out of pp
// TODO: This next line is likely redundant as all charging moves are in the above list
|| allMoves[lastMove.move].isChargingMove() // called move is a charging/recharging move
|| uninstructableMoves.includes(lastMove.move)) { // called move is in the banlist
|| movesetMove.isOutOfPp() // move out of pp
|| invalidInstructMoves.has(lastMove.move) // called move is in the banlist
) {
return false;
}
this.movesetMove = movesetMove;
@ -7988,7 +7947,7 @@ export class AfterYouAttr extends MoveEffectAttr {
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:afterYou", { targetName: getPokemonNameWithAffix(target) }));
// Will find next acting phase of the targeted pokémon, delete it and queue it right after us.
const targetNextPhase = globalScene.phaseManager.findPhase<MovePhase>(phase => phase.pokemon === target);
const targetNextPhase = globalScene.phaseManager.findPhase((phase): phase is MovePhase => phase.is("MovePhase") && phase.pokemon === target);
if (targetNextPhase && globalScene.phaseManager.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) {
globalScene.phaseManager.prependToPhase(targetNextPhase, "MovePhase");
}
@ -8016,7 +7975,7 @@ export class ForceLastAttr extends MoveEffectAttr {
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:forceLast", { targetPokemonName: getPokemonNameWithAffix(target) }));
// TODO: Refactor this to be more readable and less janky
const targetMovePhase = globalScene.phaseManager.findPhase<MovePhase>((phase) => phase.pokemon === target);
const targetMovePhase = globalScene.phaseManager.findPhase((phase): phase is MovePhase => phase.is("MovePhase") && phase.pokemon === target);
if (targetMovePhase && !targetMovePhase.isForcedLast() && globalScene.phaseManager.tryRemovePhase((phase: MovePhase) => phase.pokemon === target)) {
// Finding the phase to insert the move in front of -
// Either the end of the turn or in front of another, slower move which has also been forced last
@ -9195,11 +9154,11 @@ export function initMoves() {
.hidesUser(),
new StatusMove(MoveId.ENCORE, PokemonType.NORMAL, 100, 5, -1, 0, 2)
.attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true)
.ignoresSubstitute()
.condition((user, target, move) => new EncoreTag(user.id).canAdd(target))
.ignoresSubstitute()
.reflectable()
// Can lock infinitely into struggle; has incorrect interactions with Blood Moon/Gigaton Hammer
// Also may or may not incorrectly select targets for replacement move (needs verification)
// has incorrect interactions with Blood Moon/Gigaton Hammer
// TODO: Verify if Encore's duration decreases during status based move failures
.edgeCase(),
new AttackMove(MoveId.PURSUIT, PokemonType.DARK, MoveCategory.PHYSICAL, 40, 100, 20, -1, 0, 2)
.partial(), // No effect implemented
@ -9372,9 +9331,7 @@ export function initMoves() {
new SelfStatusMove(MoveId.MAGIC_COAT, PokemonType.PSYCHIC, -1, 15, -1, 4, 3)
.attr(AddBattlerTagAttr, BattlerTagType.MAGIC_COAT, true, true, 0)
.condition(failIfLastCondition)
// Interactions with stomping tantrum, instruct, and other moves that
// rely on move history
// Also will not reflect roar / whirlwind if the target has ForceSwitchOutImmunityAbAttr
// Will not reflect roar / whirlwind if the target has ForceSwitchOutImmunityAbAttr
.edgeCase(),
new SelfStatusMove(MoveId.RECYCLE, PokemonType.NORMAL, -1, 10, -1, 0, 3)
.unimplemented(),
@ -9383,11 +9340,11 @@ export function initMoves() {
new AttackMove(MoveId.BRICK_BREAK, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 75, 100, 15, -1, 0, 3)
.attr(RemoveScreensAttr),
new StatusMove(MoveId.YAWN, PokemonType.NORMAL, -1, 10, -1, 0, 3)
.attr(AddBattlerTagAttr, BattlerTagType.DROWSY, false, true)
.condition((user, target, move) => !target.status && !target.isSafeguarded(user))
.reflectable(),
.attr(YawnAttr)
.reflectable()
.edgeCase(), // Should not be blocked by safeguard once tag is applied
new AttackMove(MoveId.KNOCK_OFF, PokemonType.DARK, MoveCategory.PHYSICAL, 65, 100, 20, -1, 0, 3)
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getHeldItems().filter(i => i.isTransferable).length > 0 ? 1.5 : 1)
.attr(MovePowerMultiplierAttr, (_user, target, _move) => target.getHeldItems().some(i => i.isTransferable) ? 1.5 : 1)
.attr(RemoveHeldItemAttr, false)
.edgeCase(),
// Should not be able to remove held item if user faints due to Rough Skin, Iron Barbs, etc.
@ -10665,11 +10622,10 @@ export function initMoves() {
new AttackMove(MoveId.TROP_KICK, PokemonType.GRASS, MoveCategory.PHYSICAL, 70, 100, 15, 100, 0, 7)
.attr(StatStageChangeAttr, [ Stat.ATK ], -1),
new StatusMove(MoveId.INSTRUCT, PokemonType.PSYCHIC, -1, 15, -1, 0, 7)
.ignoresSubstitute()
.attr(RepeatMoveAttr)
.ignoresSubstitute()
/*
* Incorrect interactions with Gigaton Hammer, Blood Moon & Torment due to them _failing on use_, not merely being unselectable.
* Incorrectly ticks down Encore's fail counter
* TODO: Verify whether Instruct can repeat Struggle
* TODO: Verify whether Instruct can fail when using a copied move also in one's own moveset
*/

View File

@ -73,6 +73,7 @@ export class PokemonMove {
this.ppUsed = Math.min(this.ppUsed + count, this.getMovePp());
}
// TODO: Rename to `getMaxPP`
getMovePp(): number {
return this.maxPpOverride || this.getMove().pp + this.ppUp * toDmgValue(this.getMove().pp / 5);
}
@ -81,6 +82,10 @@ export class PokemonMove {
return 1 - this.ppUsed / this.getMovePp();
}
public isOutOfPp(): boolean {
return this.ppUsed >= this.getMovePp();
}
getName(): string {
return this.getMove().name;
}

View File

@ -39,6 +39,7 @@ import {
TrappedTag,
TypeImmuneTag,
} from "#data/battler-tags";
import { getDailyEventSeedBoss } from "#data/daily-run";
import { allAbilities, allMoves } from "#data/data-lists";
import { getLevelTotalExp } from "#data/exp";
import {
@ -4429,14 +4430,19 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
/**
* Return this Pokemon's move history.
* Entries are sorted in order of OLDEST to NEWEST
* @returns An array of {@linkcode TurnMove}, as described above.
* Entries are sorted in order of OLDEST to NEWEST.
* @returns An array of {@linkcode TurnMove}s, as described above.
* @see {@linkcode getLastXMoves}
*/
public getMoveHistory(): TurnMove[] {
return this.summonData.moveHistory;
}
/**
* Add a move to the end of this {@linkcode Pokemon}'s move history,
* used to record its most recently executed actions.
* @param turnMove - The {@linkcode TurnMove} to add
*/
public pushMoveHistory(turnMove: TurnMove): void {
if (!this.isOnField()) {
return;
@ -6256,6 +6262,11 @@ export class EnemyPokemon extends Pokemon {
this.species.forms[Overrides.OPP_FORM_OVERRIDES[speciesId]]
) {
this.formIndex = Overrides.OPP_FORM_OVERRIDES[speciesId];
} else if (globalScene.gameMode.isDaily && globalScene.gameMode.isWaveFinal(globalScene.currentBattle.waveIndex)) {
const eventBoss = getDailyEventSeedBoss(globalScene.seed);
if (!isNullOrUndefined(eventBoss)) {
this.formIndex = eventBoss.formIndex;
}
}
if (!dataSource) {

View File

@ -3,7 +3,7 @@ import { CHALLENGE_MODE_MYSTERY_ENCOUNTER_WAVES, CLASSIC_MODE_MYSTERY_ENCOUNTER_
import { globalScene } from "#app/global-scene";
import Overrides from "#app/overrides";
import { allChallenges, type Challenge, copyChallenge } from "#data/challenge";
import { getDailyStartingBiome } from "#data/daily-run";
import { getDailyEventSeedBoss, getDailyStartingBiome } from "#data/daily-run";
import { allSpecies } from "#data/data-lists";
import type { PokemonSpecies } from "#data/pokemon-species";
import { BiomeId } from "#enums/biome-id";
@ -15,6 +15,7 @@ import type { Arena } from "#field/arena";
import { classicFixedBattles, type FixedBattleConfigs } from "#trainers/fixed-battle-configs";
import { applyChallenges } from "#utils/challenge-utils";
import { BooleanHolder, isNullOrUndefined, randSeedInt, randSeedItem } from "#utils/common";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import i18next from "i18next";
interface GameModeConfig {
@ -211,6 +212,12 @@ export class GameMode implements GameModeConfig {
getOverrideSpecies(waveIndex: number): PokemonSpecies | null {
if (this.isDaily && this.isWaveFinal(waveIndex)) {
const eventBoss = getDailyEventSeedBoss(globalScene.seed);
if (!isNullOrUndefined(eventBoss)) {
// Cannot set form index here, it will be overriden when adding it as enemy pokemon.
return getPokemonSpecies(eventBoss.speciesId);
}
const allFinalBossSpecies = allSpecies.filter(
s =>
(s.subLegendary || s.legendary || s.mythical) &&

View File

@ -6,6 +6,7 @@ import Overrides from "#app/overrides";
import { EvolutionItem, pokemonEvolutions } from "#balance/pokemon-evolutions";
import { tmPoolTiers, tmSpecies } from "#balance/tms";
import { getBerryEffectDescription, getBerryName } from "#data/berry";
import { getDailyEventSeedLuck } from "#data/daily-run";
import { allMoves, modifierTypes } from "#data/data-lists";
import { SpeciesFormChangeItemTrigger } from "#data/form-change-triggers";
import { getNatureName, getNatureStatMultiplier } from "#data/nature";
@ -2921,6 +2922,12 @@ export function getPartyLuckValue(party: Pokemon[]): number {
const DailyLuck = new NumberHolder(0);
globalScene.executeWithSeedOffset(
() => {
const eventLuck = getDailyEventSeedLuck(globalScene.seed);
if (!isNullOrUndefined(eventLuck)) {
DailyLuck.value = eventLuck;
return;
}
DailyLuck.value = randSeedInt(15); // Random number between 0 and 14
},
0,
@ -2928,6 +2935,7 @@ export function getPartyLuckValue(party: Pokemon[]): number {
);
return DailyLuck.value;
}
const eventSpecies = timedEventManager.getEventLuckBoostedSpecies();
const luck = Phaser.Math.Clamp(
party

View File

@ -44,6 +44,7 @@ import { MoveEffectPhase } from "#phases/move-effect-phase";
import { MoveEndPhase } from "#phases/move-end-phase";
import { MoveHeaderPhase } from "#phases/move-header-phase";
import { MovePhase } from "#phases/move-phase";
import { MoveReflectPhase } from "#phases/move-reflect-phase";
import {
MysteryEncounterBattlePhase,
MysteryEncounterBattleStartCleanupPhase,
@ -157,6 +158,7 @@ const PHASES = Object.freeze({
MoveEffectPhase,
MoveEndPhase,
MoveHeaderPhase,
MoveReflectPhase,
MovePhase,
MysteryEncounterPhase,
MysteryEncounterOptionSelectedPhase,
@ -414,6 +416,8 @@ export class PhaseManager {
* @param phaseFilter filter function to use to find the wanted phase
* @returns the found phase or undefined if none found
*/
findPhase<P extends Phase = Phase>(phaseFilter: (phase: Phase) => phase is P): P | undefined;
findPhase<P extends Phase = Phase>(phaseFilter: (phase: P) => boolean): P | undefined;
findPhase<P extends Phase = Phase>(phaseFilter: (phase: P) => boolean): P | undefined {
return this.phaseQueue.find(phaseFilter) as P | undefined;
}

View File

@ -178,11 +178,6 @@ export class CommandPhase extends FieldPhase {
this.checkCommander();
const playerPokemon = this.getPokemon();
// Note: It is OK to call this if the target is not under the effect of encore; it will simply do nothing.
playerPokemon.lapseTag(BattlerTagType.ENCORE);
if (globalScene.currentBattle.turnCommands[this.fieldIndex]?.skip) {
this.end();
return;

View File

@ -1,7 +1,6 @@
import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { globalScene } from "#app/global-scene";
import { getPokemonNameWithAffix } from "#app/messages";
import type { Phase } from "#app/phase";
import { ConditionalProtectTag } from "#data/arena-tag";
import { MoveAnim } from "#data/battle-anims";
import { DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag, TypeBoostTag } from "#data/battler-tags";
@ -33,8 +32,7 @@ import {
} from "#modifiers/modifier";
import { applyFilteredMoveAttrs, applyMoveAttrs } from "#moves/apply-attrs";
import type { Move, MoveAttr } from "#moves/move";
import { getMoveTargets, isFieldTargeted } from "#moves/move-utils";
import { PokemonMove } from "#moves/pokemon-move";
import { isFieldTargeted } from "#moves/move-utils";
import { PokemonPhase } from "#phases/pokemon-phase";
import { DamageAchv } from "#system/achv";
import type { DamageResult } from "#types/damage-result";
@ -67,12 +65,6 @@ export class MoveEffectPhase extends PokemonPhase {
/** Is this the last strike of a move? */
private lastHit: boolean;
/**
* Phases queued during moves; used to add a new MovePhase for reflected moves after triggering.
* TODO: Remove this and move the reflection logic to ability-side
*/
private queuedPhases: Phase[] = [];
/**
* @param useMode - The {@linkcode MoveUseMode} corresponding to how this move was used.
*/
@ -95,143 +87,11 @@ export class MoveEffectPhase extends PokemonPhase {
this.hitChecks = Array(this.targets.length).fill([HitCheckResult.PENDING, 0]);
}
/**
* Compute targets and the results of hit checks of the invoked move against all targets,
* organized by battler index.
*
* **This is *not* a pure function**; it has the following side effects
* - `this.hitChecks` - The results of the hit checks against each target
* - `this.moveHistoryEntry` - Sets success or failure based on the hit check results
* - user.turnData.hitCount and user.turnData.hitsLeft - Both set to 1 if the
* move was unsuccessful against all targets
*
* @returns The targets of the invoked move
* @see {@linkcode hitCheck}
*/
private conductHitChecks(user: Pokemon, fieldMove: boolean): Pokemon[] {
/** All Pokemon targeted by this phase's invoked move */
/** Whether any hit check ended in a success */
let anySuccess = false;
/** Whether the attack missed all of its targets */
let allMiss = true;
let targets = this.getTargets();
// For field targeted moves, we only look for the first target that may magic bounce
for (const [i, target] of targets.entries()) {
const hitCheck = this.hitCheck(target);
// If the move bounced and was a field targeted move,
// then immediately stop processing other targets
if (fieldMove && hitCheck[0] === HitCheckResult.REFLECTED) {
targets = [target];
this.hitChecks = [hitCheck];
break;
}
if (hitCheck[0] === HitCheckResult.HIT) {
anySuccess = true;
} else {
allMiss ||= hitCheck[0] === HitCheckResult.MISS;
}
this.hitChecks[i] = hitCheck;
}
if (anySuccess) {
this.moveHistoryEntry.result = MoveResult.SUCCESS;
} else {
user.turnData.hitCount = 1;
user.turnData.hitsLeft = 1;
this.moveHistoryEntry.result = allMiss ? MoveResult.MISS : MoveResult.FAIL;
}
return targets;
}
/**
* Queue the phaes that should occur when the target reflects the move back to the user
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} that is reflecting the move
* TODO: Rework this to use `onApply` of Magic Coat
*/
private queueReflectedMove(user: Pokemon, target: Pokemon): void {
const newTargets = this.move.isMultiTarget()
? getMoveTargets(target, this.move.id).targets
: [user.getBattlerIndex()];
// TODO: ability displays should be handled by the ability
if (!target.getTag(BattlerTagType.MAGIC_COAT)) {
this.queuedPhases.push(
globalScene.phaseManager.create(
"ShowAbilityPhase",
target.getBattlerIndex(),
target.getPassiveAbility().hasAttr("ReflectStatusMoveAbAttr"),
),
);
this.queuedPhases.push(globalScene.phaseManager.create("HideAbilityPhase"));
}
this.queuedPhases.push(
globalScene.phaseManager.create(
"MovePhase",
target,
newTargets,
new PokemonMove(this.move.id),
MoveUseMode.REFLECTED,
),
);
}
/**
* Apply the move to each of the resolved targets.
* @param targets - The resolved set of targets of the move
* @throws Error if there was an unexpected hit check result
*/
private applyToTargets(user: Pokemon, targets: Pokemon[]): void {
let firstHit = true;
for (const [i, target] of targets.entries()) {
const [hitCheckResult, effectiveness] = this.hitChecks[i];
switch (hitCheckResult) {
case HitCheckResult.HIT:
this.applyMoveEffects(target, effectiveness, firstHit);
firstHit = false;
if (isFieldTargeted(this.move)) {
// Stop processing other targets if the move is a field move
return;
}
break;
// biome-ignore lint/suspicious/noFallthroughSwitchClause: The fallthrough is intentional
case HitCheckResult.NO_EFFECT:
globalScene.phaseManager.queueMessage(
i18next.t(this.move.id === MoveId.SHEER_COLD ? "battle:hitResultImmune" : "battle:hitResultNoEffect", {
pokemonName: getPokemonNameWithAffix(target),
}),
);
case HitCheckResult.NO_EFFECT_NO_MESSAGE:
case HitCheckResult.PROTECTED:
case HitCheckResult.TARGET_NOT_ON_FIELD:
applyMoveAttrs("NoEffectAttr", user, target, this.move);
break;
case HitCheckResult.MISS:
globalScene.phaseManager.queueMessage(
i18next.t("battle:attackMissed", { pokemonNameWithAffix: getPokemonNameWithAffix(target) }),
);
applyMoveAttrs("MissEffectAttr", user, target, this.move);
break;
case HitCheckResult.REFLECTED:
this.queueReflectedMove(user, target);
break;
case HitCheckResult.PENDING:
case HitCheckResult.ERROR:
throw new Error("Unexpected hit check result");
}
}
}
public override start(): void {
super.start();
/** The Pokemon using this phase's invoked move */
const user = this.getUserPokemon();
if (!user) {
super.end();
return;
@ -326,6 +186,58 @@ export class MoveEffectPhase extends PokemonPhase {
this.postAnimCallback(user, targets);
}
/**
* Compute targets and the results of hit checks of the invoked move against all targets,
* organized by battler index.
*
* **This is *not* a pure function**; it has the following side effects
* - `this.hitChecks` - The results of the hit checks against each target
* - `this.moveHistoryEntry` - Sets success or failure based on the hit check results
* - user.turnData.hitCount and user.turnData.hitsLeft - Both set to 1 if the
* move was unsuccessful against all targets
*
* @returns The targets of the invoked move
* @see {@linkcode hitCheck}
*/
private conductHitChecks(user: Pokemon, fieldMove: boolean): Pokemon[] {
/** All Pokemon targeted by this phase's invoked move */
/** Whether any hit check ended in a success */
let anySuccess = false;
/** Whether the attack missed all of its targets */
let allMiss = true;
let targets = this.getTargets();
// For field targeted moves, we only look for the first target that may magic bounce
for (const [i, target] of targets.entries()) {
const hitCheck = this.hitCheck(target);
// If the move bounced and was a field targeted move,
// then immediately stop processing other targets
if (fieldMove && hitCheck[0] === HitCheckResult.REFLECTED) {
targets = [target];
this.hitChecks = [hitCheck];
break;
}
if (hitCheck[0] === HitCheckResult.HIT) {
anySuccess = true;
} else {
allMiss ||= hitCheck[0] === HitCheckResult.MISS;
}
this.hitChecks[i] = hitCheck;
}
if (anySuccess) {
this.moveHistoryEntry.result = MoveResult.SUCCESS;
} else {
user.turnData.hitCount = 1;
user.turnData.hitsLeft = 1;
this.moveHistoryEntry.result = allMiss ? MoveResult.MISS : MoveResult.FAIL;
}
return targets;
}
/**
* Callback to be called after the move animation is played
*/
@ -344,9 +256,6 @@ export class MoveEffectPhase extends PokemonPhase {
return;
}
if (this.queuedPhases.length) {
globalScene.phaseManager.appendToPhase(this.queuedPhases, "MoveEndPhase");
}
const moveType = user.getMoveType(this.move, true);
if (this.move.category !== MoveCategory.STATUS && !user.stellarTypesBoosted.includes(moveType)) {
user.stellarTypesBoosted.push(moveType);
@ -360,121 +269,52 @@ export class MoveEffectPhase extends PokemonPhase {
this.end();
}
public override end(): void {
const user = this.getUserPokemon();
if (!user) {
super.end();
return;
}
/**
* If this phase isn't for the invoked move's last strike (and we still have something to hit),
* unshift another MoveEffectPhase for the next strike before ending this phase.
*/
if (--user.turnData.hitsLeft >= 1 && this.getFirstTarget()) {
this.addNextHitPhase();
super.end();
return;
}
/**
* All hits of the move have resolved by now.
* Queue message for multi-strike moves before applying Shell Bell heals & proccing Dancer-like effects.
*/
const hitsTotal = user.turnData.hitCount - Math.max(user.turnData.hitsLeft, 0);
if (hitsTotal > 1 || user.turnData.hitsLeft > 0) {
// Queue message if multiple hits occurred or were slated to occur (such as a Triple Axel miss)
globalScene.phaseManager.queueMessage(i18next.t("battle:attackHitsCount", { count: hitsTotal }));
}
globalScene.applyModifiers(HitHealModifier, this.player, user);
this.getTargets().forEach(target => {
target.turnData.moveEffectiveness = null;
});
super.end();
}
/**
* Applies reactive effects that occur when a Pokémon is hit.
* (i.e. Effect Spore, Disguise, Liquid Ooze, Beak Blast)
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the current target of this phase's invoked move
* @param hitResult - The {@linkcode HitResult} of the attempted move
* @param wasCritical - `true` if the move was a critical hit
* Apply the move to each of the resolved targets.
* @param targets - The resolved set of targets of the move
* @throws Error if there was an unexpected hit check result
*/
protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult, wasCritical = false): void {
const params = { pokemon: target, opponent: user, move: this.move, hitResult };
applyAbAttrs("PostDefendAbAttr", params);
if (wasCritical) {
applyAbAttrs("PostReceiveCritStatStageChangeAbAttr", params);
}
target.lapseTags(BattlerTagLapseType.AFTER_HIT);
}
/**
* Handles checking for and applying Flinches
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the current target of this phase's invoked move
* @param dealsDamage - `true` if the attempted move successfully dealt damage
*/
protected applyHeldItemFlinchCheck(user: Pokemon, target: Pokemon, dealsDamage: boolean): void {
if (this.move.hasAttr("FlinchAttr")) {
return;
}
if (
dealsDamage &&
!target.hasAbilityWithAttr("IgnoreMoveEffectsAbAttr") &&
!this.move.hitsSubstitute(user, target)
) {
const flinched = new BooleanHolder(false);
globalScene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched);
if (flinched.value) {
target.addTag(BattlerTagType.FLINCHED, undefined, this.move.id, user.id);
private applyToTargets(user: Pokemon, targets: Pokemon[]): void {
let firstHit = true;
for (const [i, target] of targets.entries()) {
const [hitCheckResult, effectiveness] = this.hitChecks[i];
switch (hitCheckResult) {
case HitCheckResult.HIT:
this.applyMoveEffects(target, effectiveness, firstHit);
firstHit = false;
if (isFieldTargeted(this.move)) {
// Stop processing other targets if the move is a field move
return;
}
break;
// biome-ignore lint/suspicious/noFallthroughSwitchClause: The fallthrough is intentional
case HitCheckResult.NO_EFFECT:
globalScene.phaseManager.queueMessage(
i18next.t(this.move.id === MoveId.SHEER_COLD ? "battle:hitResultImmune" : "battle:hitResultNoEffect", {
pokemonName: getPokemonNameWithAffix(target),
}),
);
case HitCheckResult.NO_EFFECT_NO_MESSAGE:
case HitCheckResult.PROTECTED:
case HitCheckResult.TARGET_NOT_ON_FIELD:
applyMoveAttrs("NoEffectAttr", user, target, this.move);
break;
case HitCheckResult.MISS:
globalScene.phaseManager.queueMessage(
i18next.t("battle:attackMissed", { pokemonNameWithAffix: getPokemonNameWithAffix(target) }),
);
applyMoveAttrs("MissEffectAttr", user, target, this.move);
break;
case HitCheckResult.REFLECTED:
globalScene.phaseManager.appendNewToPhase("MoveEndPhase", "MoveReflectPhase", target, user, this.move);
break;
case HitCheckResult.PENDING:
case HitCheckResult.ERROR:
throw new Error("Unexpected hit check result");
}
}
}
/** Return whether the target is protected by protect or a relevant conditional protection
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the target to check for protection
* @param move - The {@linkcode Move} being used
* @returns Whether the pokemon was protected
*/
private protectedCheck(user: Pokemon, target: Pokemon): boolean {
/** The {@linkcode ArenaTagSide} to which the target belongs */
const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
/** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */
const hasConditionalProtectApplied = new BooleanHolder(false);
/** Does the applied conditional protection bypass Protect-ignoring effects? */
const bypassIgnoreProtect = new BooleanHolder(false);
/** If the move is not targeting a Pokemon on the user's side, try to apply conditional protection effects */
if (!this.move.isAllyTarget()) {
globalScene.arena.applyTagsForSide(
ConditionalProtectTag,
targetSide,
false,
hasConditionalProtectApplied,
user,
target,
this.move.id,
bypassIgnoreProtect,
);
}
// TODO: Break up this chunky boolean to make it more palatable
return (
![MoveTarget.ENEMY_SIDE, MoveTarget.BOTH_SIDES].includes(this.move.moveTarget) &&
(bypassIgnoreProtect.value || !this.move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target })) &&
(hasConditionalProtectApplied.value ||
(!target.findTags(t => t instanceof DamageProtectedTag).length &&
target.findTags(t => t instanceof ProtectedTag).some(t => target.lapseTag(t.tagType))) ||
(this.move.category !== MoveCategory.STATUS &&
target.findTags(t => t instanceof DamageProtectedTag).some(t => target.lapseTag(t.tagType))))
);
}
/**
* Conduct the hit check and type effectiveness for this move against the target
*
@ -495,10 +335,6 @@ export class MoveEffectPhase extends PokemonPhase {
const user = this.getUserPokemon();
const move = this.move;
if (!user) {
return [HitCheckResult.ERROR, 0];
}
// Moves targeting the user bypass all checks
if (move.moveTarget === MoveTarget.USER) {
return [HitCheckResult.HIT, 1];
@ -532,7 +368,7 @@ export class MoveEffectPhase extends PokemonPhase {
}
// Reflected moves cannot be reflected again
if (!isReflected(this.useMode) && move.doesFlagEffectApply({ flag: MoveFlags.REFLECTABLE, user, target })) {
if (isMoveReflectableBy(this.move, target, this.useMode)) {
return [HitCheckResult.REFLECTED, 0];
}
@ -603,9 +439,6 @@ export class MoveEffectPhase extends PokemonPhase {
*/
public checkBypassAccAndInvuln(target: Pokemon) {
const user = this.getUserPokemon();
if (!user) {
return false;
}
if (user.hasAbilityWithAttr("AlwaysHitAbAttr") || target.hasAbilityWithAttr("AlwaysHitAbAttr")) {
return true;
}
@ -637,79 +470,43 @@ export class MoveEffectPhase extends PokemonPhase {
return move.getAttrs("HitsTagAttr").some(hta => hta.tagType === semiInvulnerableTag.tagType);
}
/** @returns The {@linkcode Pokemon} using this phase's invoked move */
public getUserPokemon(): Pokemon | null {
// TODO: Make this purely a battler index
if (this.battlerIndex > BattlerIndex.ENEMY_2) {
return globalScene.getPokemonById(this.battlerIndex);
}
return (this.player ? globalScene.getPlayerField() : globalScene.getEnemyField())[this.fieldIndex];
}
/**
* @returns An array of {@linkcode Pokemon} that are:
* - On-field and active
* - Non-fainted
* - Targeted by this phase's invoked move
* Check whether the target is protected by protect or a relevant conditional protection.
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The target {@linkcode Pokemon} to check for protection
* @returns Whether the target was protected
*/
public getTargets(): Pokemon[] {
return globalScene.getField(true).filter(p => this.targets.indexOf(p.getBattlerIndex()) > -1);
}
/** @returns The first active, non-fainted target of this phase's invoked move. */
public getFirstTarget(): Pokemon | undefined {
return this.getTargets()[0];
}
/**
* Removes the given {@linkcode Pokemon} from this phase's target list
* @param target - The {@linkcode Pokemon} to be removed
*/
protected removeTarget(target: Pokemon): void {
const targetIndex = this.targets.indexOf(target.getBattlerIndex());
if (targetIndex !== -1) {
this.targets.splice(this.targets.indexOf(target.getBattlerIndex()), 1);
private protectedCheck(user: Pokemon, target: Pokemon): boolean {
/** The {@linkcode ArenaTagSide} to which the target belongs */
const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
/** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */
const hasConditionalProtectApplied = new BooleanHolder(false);
/** Does the applied conditional protection bypass Protect-ignoring effects? */
const bypassIgnoreProtect = new BooleanHolder(false);
/** If the move is not targeting a Pokemon on the user's side, try to apply conditional protection effects */
if (!this.move.isAllyTarget()) {
globalScene.arena.applyTagsForSide(
ConditionalProtectTag,
targetSide,
false,
hasConditionalProtectApplied,
user,
target,
this.move.id,
bypassIgnoreProtect,
);
}
}
/**
* Prevents subsequent strikes of this phase's invoked move from occurring
* @param target - If defined, only stop subsequent strikes against this {@linkcode Pokemon}
*/
public stopMultiHit(target?: Pokemon): void {
// If given a specific target, remove the target from subsequent strikes
if (target) {
this.removeTarget(target);
}
const user = this.getUserPokemon();
if (!user) {
return;
}
// If no target specified, or the specified target was the last of this move's
// targets, completely cancel all subsequent strikes.
if (!target || this.targets.length === 0) {
user.turnData.hitCount = 1;
user.turnData.hitsLeft = 1;
}
}
/**
* Unshifts a new `MoveEffectPhase` with the same properties as this phase.
* Used to queue the next hit of multi-strike moves.
*/
protected addNextHitPhase(): void {
globalScene.phaseManager.unshiftNew("MoveEffectPhase", this.battlerIndex, this.targets, this.move, this.useMode);
}
/** Removes all substitutes that were broken by this phase's invoked move */
protected updateSubstitutes(): void {
const targets = this.getTargets();
for (const target of targets) {
const substitute = target.getTag(SubstituteTag);
if (substitute && substitute.hp <= 0) {
target.lapseTag(BattlerTagType.SUBSTITUTE);
}
}
// TODO: Break up this chunky boolean to make it more palatable
return (
![MoveTarget.ENEMY_SIDE, MoveTarget.BOTH_SIDES].includes(this.move.moveTarget) &&
(bypassIgnoreProtect.value || !this.move.doesFlagEffectApply({ flag: MoveFlags.IGNORE_PROTECT, user, target })) &&
(hasConditionalProtectApplied.value ||
(!target.findTags(t => t instanceof DamageProtectedTag).length &&
target.findTags(t => t instanceof ProtectedTag).some(t => target.lapseTag(t.tagType))) ||
(this.move.category !== MoveCategory.STATUS &&
target.findTags(t => t instanceof DamageProtectedTag).some(t => target.lapseTag(t.tagType))))
);
}
/**
@ -757,9 +554,6 @@ export class MoveEffectPhase extends PokemonPhase {
*/
protected applyMoveEffects(target: Pokemon, effectiveness: TypeDamageMultiplier, firstTarget: boolean): void {
const user = this.getUserPokemon();
if (isNullOrUndefined(user)) {
return;
}
this.triggerMoveEffects(MoveEffectTrigger.PRE_APPLY, user, target);
@ -783,7 +577,33 @@ export class MoveEffectPhase extends PokemonPhase {
}
/**
* Sub-method of for {@linkcode applyMoveEffects} that applies damage to the target.
* Apply the result of this phase's move to the given target
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} struck by the move
* @param effectiveness - The effectiveness of the move against the target
*/
protected applyMove(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): [HitResult, boolean] {
const moveCategory = user.getMoveCategory(target, this.move);
if (moveCategory === MoveCategory.STATUS) {
return [HitResult.STATUS, false];
}
const result = this.applyMoveDamage(user, target, effectiveness);
if (user.turnData.hitsLeft === 1 || target.isFainted()) {
this.queueHitResultMessage(result[0]);
}
if (target.isFainted()) {
this.onFaintTarget(user, target);
}
return result;
}
/**
* Sub-method of {@linkcode applyMove} that applies damage to the target.
*
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} targeted by the move
@ -882,6 +702,29 @@ export class MoveEffectPhase extends PokemonPhase {
return [result, isCritical];
}
/**
* Sub-method of {@linkcode applyMove} that queues the hit-result message
* on the final strike of the move against a target
* @param result - The {@linkcode HitResult} of the move
*/
protected queueHitResultMessage(result: HitResult) {
let msg: string | undefined;
switch (result) {
case HitResult.SUPER_EFFECTIVE:
msg = i18next.t("battle:hitResultSuperEffective");
break;
case HitResult.NOT_VERY_EFFECTIVE:
msg = i18next.t("battle:hitResultNotVeryEffective");
break;
case HitResult.ONE_HIT_KO:
msg = i18next.t("battle:hitResultOneHitKO");
break;
}
if (msg) {
globalScene.phaseManager.queueMessage(msg);
}
}
/**
* Sub-method of {@linkcode applyMove} that handles the event of a target fainting.
* @param user - The {@linkcode Pokemon} using this phase's invoked move
@ -906,55 +749,7 @@ export class MoveEffectPhase extends PokemonPhase {
}
/**
* Sub-method of {@linkcode applyMove} that queues the hit-result message
* on the final strike of the move against a target
* @param result - The {@linkcode HitResult} of the move
*/
protected queueHitResultMessage(result: HitResult) {
let msg: string | undefined;
switch (result) {
case HitResult.SUPER_EFFECTIVE:
msg = i18next.t("battle:hitResultSuperEffective");
break;
case HitResult.NOT_VERY_EFFECTIVE:
msg = i18next.t("battle:hitResultNotVeryEffective");
break;
case HitResult.ONE_HIT_KO:
msg = i18next.t("battle:hitResultOneHitKO");
break;
}
if (msg) {
globalScene.phaseManager.queueMessage(msg);
}
}
/** Apply the result of this phase's move to the given target
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - The {@linkcode Pokemon} struck by the move
* @param effectiveness - The effectiveness of the move against the target
*/
protected applyMove(user: Pokemon, target: Pokemon, effectiveness: TypeDamageMultiplier): [HitResult, boolean] {
const moveCategory = user.getMoveCategory(target, this.move);
if (moveCategory === MoveCategory.STATUS) {
return [HitResult.STATUS, false];
}
const result = this.applyMoveDamage(user, target, effectiveness);
if (user.turnData.hitsLeft === 1 || target.isFainted()) {
this.queueHitResultMessage(result[0]);
}
if (target.isFainted()) {
this.onFaintTarget(user, target);
}
return result;
}
/**
* Applies all effects aimed at the move's target.
* Sub-method of {@linkcode applyMovetEffects} that applies all effects aimed at the move's target.
* To be used when the target is successfully and directly hit by the move.
* @param user - The {@linkcode Pokemon} using the move
* @param target - The {@linkcode Pokemon} targeted by the move
@ -992,4 +787,173 @@ export class MoveEffectPhase extends PokemonPhase {
globalScene.applyModifiers(ContactHeldItemTransferChanceModifier, this.player, user, target);
}
}
/**
* Sub-method of {@linkcode applyOnTargetEffects} that applies reactive effects that occur when a Pokémon is hit.
* (i.e. Effect Spore, Disguise, Liquid Ooze, Beak Blast)
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the current target of this phase's invoked move
* @param hitResult - The {@linkcode HitResult} of the attempted move
* @param wasCritical - `true` if the move was a critical hit
*/
protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult, wasCritical = false): void {
const params = { pokemon: target, opponent: user, move: this.move, hitResult };
applyAbAttrs("PostDefendAbAttr", params);
if (wasCritical) {
applyAbAttrs("PostReceiveCritStatStageChangeAbAttr", params);
}
target.lapseTags(BattlerTagLapseType.AFTER_HIT);
}
/**
* Sub-method of {@linkcode applyOnTargetEffects} that handles checking for and applying flinches.
* @param user - The {@linkcode Pokemon} using this phase's invoked move
* @param target - {@linkcode Pokemon} the current target of this phase's invoked move
* @param dealsDamage - `true` if the attempted move successfully dealt damage
*/
protected applyHeldItemFlinchCheck(user: Pokemon, target: Pokemon, dealsDamage: boolean): void {
if (this.move.hasAttr("FlinchAttr")) {
return;
}
if (
dealsDamage &&
!target.hasAbilityWithAttr("IgnoreMoveEffectsAbAttr") &&
!this.move.hitsSubstitute(user, target)
) {
const flinched = new BooleanHolder(false);
globalScene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched);
if (flinched.value) {
target.addTag(BattlerTagType.FLINCHED, undefined, this.move.id, user.id);
}
}
}
public override end(): void {
const user = this.getUserPokemon();
/**
* If this phase isn't for the invoked move's last strike (and we still have something to hit),
* unshift another MoveEffectPhase for the next strike before ending this phase.
*/
if (--user.turnData.hitsLeft >= 1 && this.getFirstTarget()) {
this.addNextHitPhase();
super.end();
return;
}
/**
* All hits of the move have resolved by now.
* Queue message for multi-strike moves before applying Shell Bell heals & proccing Dancer-like effects.
*/
const hitsTotal = user.turnData.hitCount - Math.max(user.turnData.hitsLeft, 0);
if (hitsTotal > 1 || user.turnData.hitsLeft > 0) {
// Queue message if multiple hits occurred or were slated to occur (such as a Triple Axel miss)
globalScene.phaseManager.queueMessage(i18next.t("battle:attackHitsCount", { count: hitsTotal }));
}
globalScene.applyModifiers(HitHealModifier, this.player, user);
this.getTargets().forEach(target => {
target.turnData.moveEffectiveness = null;
});
super.end();
}
// #region Helpers
/**
* @returns The {@linkcode Pokemon} using this phase's invoked move.
* Is never null during the move execution itself, as {@linkcode start} ends the phase immediately if a source is missing.
* @todo Delete in favor of {@linkcode PokemonPhase.getPokemon}
*/
public getUserPokemon(): Pokemon {
return super.getPokemon()!;
}
/**
* @returns An array of {@linkcode Pokemon} that are:
* - On-field and active
* - Non-fainted
* - Targeted by this phase's invoked move
*/
public getTargets(): Pokemon[] {
return globalScene.getField(true).filter(p => this.targets.indexOf(p.getBattlerIndex()) > -1);
}
/** @returns The first active, non-fainted target of this phase's invoked move. */
public getFirstTarget(): Pokemon | undefined {
return this.getTargets()[0];
}
/**
* Removes the given {@linkcode Pokemon} from this phase's target list
* @param target - The {@linkcode Pokemon} to be removed
*/
protected removeTarget(target: Pokemon): void {
const targetIndex = this.targets.indexOf(target.getBattlerIndex());
if (targetIndex !== -1) {
this.targets.splice(this.targets.indexOf(target.getBattlerIndex()), 1);
}
}
/**
* Prevents subsequent strikes of this phase's invoked move from occurring
* @param target - If defined, only stop subsequent strikes against this {@linkcode Pokemon}
*/
public stopMultiHit(target?: Pokemon): void {
// If given a specific target, remove the target from subsequent strikes
if (target) {
this.removeTarget(target);
}
const user = this.getUserPokemon();
// If no target specified, or the specified target was the last of this move's
// targets, completely cancel all subsequent strikes.
if (!target || this.targets.length === 0) {
user.turnData.hitCount = 1;
user.turnData.hitsLeft = 1;
}
}
/**
* Unshifts a new `MoveEffectPhase` with the same properties as this phase.
* Used to queue the next hit of multi-strike moves.
*/
protected addNextHitPhase(): void {
globalScene.phaseManager.unshiftNew("MoveEffectPhase", this.battlerIndex, this.targets, this.move, this.useMode);
}
/** Removes all substitutes that were broken by this phase's invoked move */
protected updateSubstitutes(): void {
const targets = this.getTargets();
for (const target of targets) {
const substitute = target.getTag(SubstituteTag);
if (substitute && substitute.hp <= 0) {
target.lapseTag(BattlerTagType.SUBSTITUTE);
}
}
}
// # endregion Helpers
}
/**
* Check whether a given Move is able to be reflected by either
* {@linkcode MoveId.MAGIC_COAT | Magic Coat} or {@linkcode AbilityId.MAGIC_BOUNCE | Magic Bounce}.
* @param move - The {@linkcode Move} being used
* @param target - The targeted {@linkcode Pokemon} attempting to reflect the move
* @param useMode - The {@linkcode MoveUseMode} dictating how the move was used
* @returns Whether {@linkcode target} can reflect {@linkcode move}.
*/
function isMoveReflectableBy(move: Move, target: Pokemon, useMode: MoveUseMode): boolean {
return (
// The move must not have just been reflected
!isReflected(useMode) &&
// Reflections cannot occur while semi invulnerable
!target.getTag(SemiInvulnerableTag) &&
// Move must be reflectable
move.hasFlag(MoveFlags.REFLECTABLE) &&
// target must have a reflection effect active
(!!target.getTag(BattlerTagType.MAGIC_COAT) || target.hasAbilityWithAttr("ReflectStatusMoveAbAttr"))
);
}

View File

@ -0,0 +1,41 @@
import { applyAbAttrs } from "#abilities/apply-ab-attrs";
import { Phase } from "#app/phase";
import type { MagicCoatTag } from "#data/battler-tags";
import { BattlerTagType } from "#enums/battler-tag-type";
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc
import type { MoveId } from "#enums/move-id";
import type { Pokemon } from "#field/pokemon";
import type { Move } from "#types/move-types";
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc
/**
* The phase where Pokemon reflect moves via {@linkcode MoveId.MAGIC_COAT | Magic Coat} or {@linkcode AbilityId.MAGIC_BOUNCE | Magic Bounce}.
*/
export class MoveReflectPhase extends Phase {
public override readonly phaseName = "MoveReflectPhase";
/** The {@linkcode Pokemon} doing the reflecting. */
private readonly pokemon: Pokemon;
/** The pokemon having originally used the move. */
private opponent: Pokemon;
/** The {@linkcode Move} being reflected. */
private readonly move: Move;
constructor(pokemon: Pokemon, opponent: Pokemon, move: Move) {
super();
this.pokemon = pokemon;
this.opponent = opponent;
this.move = move;
}
override start(): void {
this.pokemon.turnData.extraTurns++;
// Magic Coat takes precedeence over Magic Bounce if both apply at once
const magicCoatTag = this.pokemon.getTag(BattlerTagType.MAGIC_COAT) as MagicCoatTag | undefined;
if (magicCoatTag) {
magicCoatTag.apply(this.pokemon, this.opponent, this.move);
} else {
applyAbAttrs("ReflectStatusMoveAbAttr", { pokemon: this.pokemon, opponent: this.opponent, move: this.move });
}
super.end();
}
}

View File

@ -1,345 +0,0 @@
import { allAbilities, allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Abilities - Magic Bounce", () => {
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")
.moveset([MoveId.GROWL, MoveId.SPLASH])
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.MAGIC_BOUNCE)
.enemyMoveset(MoveId.SPLASH);
});
it("should reflect basic status moves", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should not bounce moves while the target is in the semi-invulnerable state", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL);
await game.move.forceEnemyMove(MoveId.FLY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(0);
});
it("should individually bounce back multi-target moves", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL, 0);
game.move.use(MoveId.SPLASH, 1);
await game.phaseInterceptor.to("BerryPhase");
const user = game.scene.getPlayerField()[0];
expect(user.getStatStage(Stat.ATK)).toBe(-2);
});
it("should still bounce back a move that would otherwise fail", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.field.getEnemyPokemon().setStatStage(Stat.ATK, -6);
game.move.use(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should not bounce back a move that was just bounced", async () => {
game.override.ability(AbilityId.MAGIC_BOUNCE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should receive the stat change after reflecting a move back to a mirror armor user", async () => {
game.override.ability(AbilityId.MIRROR_ARMOR);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should not bounce back a move from a mold breaker user", async () => {
game.override.ability(AbilityId.MOLD_BREAKER);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should bounce back a spread status move against both pokemon", async () => {
game.override.battleStyle("double").enemyMoveset([MoveId.SPLASH]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL, 0);
game.move.use(MoveId.SPLASH, 1);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerField().every(p => p.getStatStage(Stat.ATK) === -2)).toBeTruthy();
});
it("should only bounce spikes back once in doubles when both targets have magic bounce", async () => {
game.override.battleStyle("double").moveset([MoveId.SPIKES]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.SPIKES);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)).toBeUndefined();
});
it("should bounce spikes even when the target is protected", async () => {
game.override.moveset([MoveId.SPIKES]).enemyMoveset([MoveId.PROTECT]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.SPIKES);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
});
it("should not bounce spikes when the target is in the semi-invulnerable state", async () => {
game.override.moveset([MoveId.SPIKES]).enemyMoveset([MoveId.FLY]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.SPIKES);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)!["layers"]).toBe(1);
});
it("should not bounce back curse", async () => {
game.override.moveset([MoveId.CURSE]);
await game.classicMode.startBattle([SpeciesId.GASTLY]);
game.move.select(MoveId.CURSE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getTag(BattlerTagType.CURSED)).toBeDefined();
});
// TODO: enable when Magic Bounce is fixed to properly reset the hit count
it.todo("should not cause encore to be interrupted after bouncing", async () => {
game.override.moveset([MoveId.SPLASH, MoveId.GROWL, MoveId.ENCORE]).enemyMoveset([MoveId.TACKLE, MoveId.GROWL]);
// game.override.ability(AbilityId.MOLD_BREAKER);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
// Give the player MOLD_BREAKER for this turn to bypass Magic Bounce.
const playerAbilitySpy = game.field.mockAbility(playerPokemon, AbilityId.MOLD_BREAKER);
// turn 1
game.move.select(MoveId.ENCORE);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE);
// turn 2
playerAbilitySpy.mockRestore();
game.move.select(MoveId.GROWL);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE);
expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE);
});
// TODO: encore is failing if the last move was virtual.
it.todo("should not cause the bounced move to count for encore", async () => {
game.override
.moveset([MoveId.SPLASH, MoveId.GROWL, MoveId.ENCORE])
.enemyMoveset([MoveId.GROWL, MoveId.TACKLE])
.enemyAbility(AbilityId.MAGIC_BOUNCE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
// turn 1
game.move.select(MoveId.GROWL);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
// Give the player MOLD_BREAKER for this turn to bypass Magic Bounce.
vi.spyOn(playerPokemon, "getAbility").mockReturnValue(allAbilities[AbilityId.MOLD_BREAKER]);
// turn 2
game.move.select(MoveId.ENCORE);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE);
expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE);
});
// TODO: stomping tantrum should consider moves that were bounced.
it.todo("should cause stomping tantrum to double in power when the last move was bounced", async () => {
game.override.battleStyle("single").moveset([MoveId.STOMPING_TANTRUM, MoveId.CHARM]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const stomping_tantrum = allMoves[MoveId.STOMPING_TANTRUM];
vi.spyOn(stomping_tantrum, "calculateBattlePower");
game.move.select(MoveId.CHARM);
await game.toNextTurn();
game.move.select(MoveId.STOMPING_TANTRUM);
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150);
});
// TODO: stomping tantrum should consider moves that were bounced
it.todo("should boost enemy's stomping tantrum after failed bounce", async () => {
game.override.enemyMoveset([MoveId.STOMPING_TANTRUM, MoveId.SPLASH, MoveId.CHARM]);
await game.classicMode.startBattle([SpeciesId.BULBASAUR]);
const stomping_tantrum = allMoves[MoveId.STOMPING_TANTRUM];
const enemy = game.field.getEnemyPokemon();
vi.spyOn(stomping_tantrum, "calculateBattlePower");
// Spore gets reflected back onto us
game.move.select(MoveId.SPORE);
await game.move.selectEnemyMove(MoveId.CHARM);
await game.toNextTurn();
expect(enemy.getLastXMoves(1)[0].result).toBe("success");
game.move.select(MoveId.SPORE);
await game.move.selectEnemyMove(MoveId.STOMPING_TANTRUM);
await game.toNextTurn();
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150);
});
it("should respect immunities when bouncing a move", async () => {
vi.spyOn(allMoves[MoveId.THUNDER_WAVE], "accuracy", "get").mockReturnValue(100);
game.override.moveset([MoveId.THUNDER_WAVE, MoveId.GROWL]).ability(AbilityId.SOUNDPROOF);
await game.classicMode.startBattle([SpeciesId.PHANPY]);
// Turn 1 - thunder wave immunity test
game.move.select(MoveId.THUNDER_WAVE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().status).toBeUndefined();
// Turn 2 - soundproof immunity test
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(0);
});
it("should bounce back a move before the accuracy check", async () => {
game.override.moveset([MoveId.SPORE]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const attacker = game.field.getPlayerPokemon();
vi.spyOn(attacker, "getAccuracyMultiplier").mockReturnValue(0.0);
game.move.select(MoveId.SPORE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().status?.effect).toBe(StatusEffect.SLEEP);
});
it("should take the accuracy of the magic bounce user into account", async () => {
game.override.moveset([MoveId.SPORE]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const opponent = game.field.getEnemyPokemon();
vi.spyOn(opponent, "getAccuracyMultiplier").mockReturnValue(0);
game.move.select(MoveId.SPORE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().status).toBeUndefined();
});
it("should always apply the leftmost available target's magic bounce when bouncing moves like sticky webs in doubles", async () => {
game.override.battleStyle("double").moveset([MoveId.STICKY_WEB, MoveId.SPLASH, MoveId.TRICK_ROOM]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
const [enemy_1, enemy_2] = game.scene.getEnemyField();
// set speed just incase logic erroneously checks for speed order
enemy_1.setStat(Stat.SPD, enemy_2.getStat(Stat.SPD) + 1);
// turn 1
game.move.select(MoveId.STICKY_WEB, 0);
game.move.select(MoveId.TRICK_ROOM, 1);
await game.phaseInterceptor.to("TurnEndPhase");
expect(
game.scene.arena
.getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER)
?.getSourcePokemon()
?.getBattlerIndex(),
).toBe(BattlerIndex.ENEMY);
game.scene.arena.removeTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER, true);
// turn 2
game.move.select(MoveId.STICKY_WEB, 0);
game.move.select(MoveId.TRICK_ROOM, 1);
await game.phaseInterceptor.to("BerryPhase");
expect(
game.scene.arena
.getTagOnSide(ArenaTagType.STICKY_WEB, ArenaTagSide.PLAYER)
?.getSourcePokemon()
?.getBattlerIndex(),
).toBe(BattlerIndex.ENEMY);
});
it("should not bounce back status moves that hit through semi-invulnerable states", async () => {
game.override.moveset([MoveId.TOXIC, MoveId.CHARM]);
await game.classicMode.startBattle([SpeciesId.BULBASAUR]);
game.move.select(MoveId.TOXIC);
await game.move.selectEnemyMove(MoveId.FLY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().status?.effect).toBe(StatusEffect.TOXIC);
expect(game.field.getPlayerPokemon().status).toBeUndefined();
game.override.ability(AbilityId.NO_GUARD);
game.move.select(MoveId.CHARM);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-2);
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(0);
});
});

View File

@ -7,8 +7,6 @@ import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
// TODO: When Magic Bounce is implemented, make a test for its interaction with mirror guard, use screech
describe("Ability - Mirror Armor", () => {
let phaserGame: Phaser.Game;
let game: GameManager;

View File

@ -385,5 +385,5 @@ describe("Moves - Delayed Attacks", () => {
});
// TODO: Implement and move to a power spot's test file
it.todo("Should activate ally's power spot when switched in during single battles");
it.todo("should activate ally's power spot when switched in during single battles");
});

View File

@ -1,10 +1,14 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { MoveUseMode } from "#enums/move-use-mode";
import { SpeciesId } from "#enums/species-id";
import { invalidEncoreMoves } from "#moves/invalid-moves";
import { GameManager } from "#test/test-utils/game-manager";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -31,7 +35,6 @@ describe("Moves - Encore", () => {
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset([MoveId.SPLASH, MoveId.TACKLE])
.startingLevel(100)
.enemyLevel(100);
});
@ -39,76 +42,154 @@ describe("Moves - Encore", () => {
it("should prevent the target from using any move except the last used move", async () => {
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const enemyPokemon = game.field.getEnemyPokemon();
const enemy = game.field.getEnemyPokemon();
game.move.select(MoveId.ENCORE);
await game.move.selectEnemyMove(MoveId.SPLASH);
game.move.use(MoveId.ENCORE);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE);
expect(enemy.isMoveRestricted(MoveId.TACKLE)).toBe(true);
expect(enemy.isMoveRestricted(MoveId.SPLASH)).toBe(false);
});
it("should be removed on turn end after triggering thrice, ignoring Instruct", async () => {
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const enemy = game.field.getEnemyPokemon();
enemy.pushMoveHistory({ move: MoveId.SPLASH, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
game.move.use(MoveId.ENCORE);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
// Should have ticked down once
expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE);
expect(enemy.getTag(BattlerTagType.ENCORE)!.turnCount).toBe(2);
game.move.use(MoveId.INSTRUCT);
await game.toNextTurn();
expect(enemy.getTag(BattlerTagType.ENCORE)!.turnCount).toBe(1);
game.move.use(MoveId.INSTRUCT);
await game.toEndOfTurn(false);
// Tag should still be present until the `TurnEndPhase` ticks it down
expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE);
await game.toNextTurn();
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)).toBeDefined();
game.move.select(MoveId.SPLASH);
// The enemy AI would normally be inclined to use Tackle, but should be
// forced into using Splash.
await game.phaseInterceptor.to("BerryPhase", false);
expect(enemyPokemon.getLastXMoves().every(turnMove => turnMove.move === MoveId.SPLASH)).toBeTruthy();
expect(enemy).not.toHaveBattlerTag(BattlerTagType.ENCORE);
expect(game.textInterceptor.logs).toContain(
i18next.t("battlerTags:encoreOnRemove", {
pokemonNameWithAffix: getPokemonNameWithAffix(enemy),
}),
);
});
describe("should fail against the following moves:", () => {
it.each([
{ moveId: MoveId.TRANSFORM, name: "Transform", delay: false },
{ moveId: MoveId.MIMIC, name: "Mimic", delay: true },
{ moveId: MoveId.SKETCH, name: "Sketch", delay: true },
{ moveId: MoveId.ENCORE, name: "Encore", delay: false },
{ moveId: MoveId.STRUGGLE, name: "Struggle", delay: false },
])("$name", async ({ moveId, delay }) => {
game.override.enemyMoveset(moveId);
it("should override any upcoming moves with the Encored move, while still consuming PP", async () => {
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
// Fake enemy having used tackle the turn prior
const enemy = game.field.getEnemyPokemon();
game.move.changeMoveset(enemy, [MoveId.SPLASH, MoveId.TACKLE]);
enemy.pushMoveHistory({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.use(MoveId.ENCORE);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
if (delay) {
game.move.select(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
}
game.move.select(MoveId.ENCORE);
const turnOrder = delay ? [BattlerIndex.PLAYER, BattlerIndex.ENEMY] : [BattlerIndex.ENEMY, BattlerIndex.PLAYER];
await game.setTurnOrder(turnOrder);
await game.phaseInterceptor.to("BerryPhase", false);
expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)).toBeUndefined();
});
expect(enemy).toHaveUsedMove({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
expect(enemy).toHaveUsedPP(MoveId.TACKLE, 1);
});
it("Pokemon under both Encore and Torment should alternate between Struggle and restricted move", async () => {
const turnOrder = [BattlerIndex.ENEMY, BattlerIndex.PLAYER];
game.override.moveset([MoveId.ENCORE, MoveId.TORMENT, MoveId.SPLASH]);
// TODO: Make test using `changeMoveset`
it.todo("should end at turn end if the user forgets the Encored move");
it("should be removed at turn end if the Encored move runs out of PP", async () => {
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
// Fake enemy having used tackle the turn prior
const enemy = game.field.getEnemyPokemon();
game.move.changeMoveset(enemy, [MoveId.SPLASH, MoveId.TACKLE]);
enemy.pushMoveHistory({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
enemy.moveset[1].ppUsed = enemy.moveset[1].getMovePp() - 2;
game.move.use(MoveId.ENCORE);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
expect(enemy).toHaveUsedMove({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
expect(enemy).toHaveUsedPP(MoveId.TACKLE, enemy.moveset[1].getMovePp() - 1);
expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE);
game.move.use(MoveId.SPLASH);
await game.toEndOfTurn();
expect(enemy).toHaveUsedPP(MoveId.TACKLE, "all");
expect(enemy).not.toHaveBattlerTag(BattlerTagType.ENCORE);
});
const invalidMoves = [...invalidEncoreMoves].map(m => ({
name: MoveId[m],
move: m,
}));
it.each(invalidMoves)("should fail if the target's last move is $name", async ({ move }) => {
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
enemy.pushMoveHistory({ move, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
game.move.use(MoveId.ENCORE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(player.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
expect(enemy).not.toHaveBattlerTag(BattlerTagType.ENCORE);
});
it("should fail if the target has not made a move", async () => {
await game.classicMode.startBattle([SpeciesId.SNORLAX]);
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.ENCORE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(player.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
expect(enemy).not.toHaveBattlerTag(BattlerTagType.ENCORE);
});
it("should force a Tormented target to alternate between Struggle and the Encored move", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.ENCORE);
await game.setTurnOrder(turnOrder);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)).toBeDefined();
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.ENCORE);
await game.move.forceEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
game.move.select(MoveId.TORMENT);
await game.setTurnOrder(turnOrder);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.TORMENT)).toBeDefined();
expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE);
game.move.use(MoveId.TORMENT);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
game.move.select(MoveId.SPLASH);
await game.setTurnOrder(turnOrder);
await game.phaseInterceptor.to("BerryPhase");
const lastMove = enemyPokemon.getLastXMoves()[0];
expect(lastMove?.move).toBe(MoveId.STRUGGLE);
expect(enemy).toHaveBattlerTag(BattlerTagType.ENCORE);
expect(enemy).toHaveBattlerTag(BattlerTagType.TORMENT);
game.move.use(MoveId.SPLASH);
await game.toEndOfTurn();
expect(enemy).toHaveUsedMove(MoveId.STRUGGLE);
});
});

View File

@ -0,0 +1,390 @@
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { MoveUseMode } from "#enums/move-use-mode";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Reflecting effects", () => {
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)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.MAGIC_COAT);
});
describe("Reflecting effects", () => {
it("should reflect basic status moves, copying them against the user", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL);
await game.toEndOfTurn();
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
expect(enemy).toHaveUsedMove({
move: MoveId.GROWL,
useMode: MoveUseMode.REFLECTED,
targets: [BattlerIndex.PLAYER],
});
expect(player).toHaveStatStage(Stat.ATK, -1);
});
it("should bounce back multi-target moves against each target", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL, BattlerIndex.PLAYER);
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
await game.toEndOfTurn();
const [karp1, karp2] = game.scene.getPlayerField();
expect(karp1).toHaveStatStage(Stat.ATK, -2);
expect(karp2).toHaveStatStage(Stat.ATK, -2);
});
// TODO: This is broken - failed moves never make it to a MEP
it.todo("should still bounce back a move that would otherwise fail", async () => {
game.override.enemyAbility(AbilityId.INSOMNIA);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.YAWN);
await game.toEndOfTurn();
expect(game.field.getPlayerPokemon()).toHaveBattlerTag(BattlerTagType.DROWSY);
});
it("should not bounce back a move that was just bounced", async () => {
game.override.battleStyle("double").ability(AbilityId.MAGIC_BOUNCE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
game.move.use(MoveId.MAGIC_COAT, BattlerIndex.PLAYER);
game.move.use(MoveId.GROWL, BattlerIndex.PLAYER_2);
await game.move.forceEnemyMove(MoveId.MAGIC_COAT);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, 0);
});
it("should take precedence over Mirror Armor", async () => {
game.override.enemyAbility(AbilityId.MIRROR_ARMOR);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL);
await game.toEndOfTurn();
const enemy = game.field.getPlayerPokemon();
expect(enemy).toHaveStatStage(Stat.ATK, -1);
expect(enemy).not.toHaveAbilityApplied(AbilityId.MIRROR_ARMOR);
});
it("should not bounce back non-reflectable effects", async () => {
await game.classicMode.startBattle([SpeciesId.GASTLY]);
game.move.use(MoveId.CURSE);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon()).toHaveBattlerTag(BattlerTagType.CURSED);
});
it("should not cause encore to be interrupted after bouncing", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
// Give the player MOLD_BREAKER for this turn to bypass Magic Bounce.
const playerAbilitySpy = game.field.mockAbility(playerPokemon, AbilityId.MOLD_BREAKER);
// turn 1
game.move.use(MoveId.ENCORE);
await game.move.forceEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE);
// turn 2
playerAbilitySpy.mockRestore();
game.move.use(MoveId.GROWL);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE);
expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE);
});
it("should not cause the bounced move to count for encore", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.ABRA]);
// Fake abra having mold breaker and the enemy having used Tackle
const [, abra, enemy1] = game.scene.getField();
game.field.mockAbility(abra, AbilityId.MOLD_BREAKER);
game.field.mockAbility(enemy1, AbilityId.MAGIC_BOUNCE);
game.move.changeMoveset(enemy1, [MoveId.TACKLE, MoveId.SPLASH]);
enemy1.pushMoveHistory({ move: MoveId.TACKLE, targets: [BattlerIndex.PLAYER], useMode: MoveUseMode.NORMAL });
// Magikarp uses growl as Abra attempts to encore enemy 1
game.move.use(MoveId.GROWL, BattlerIndex.PLAYER);
game.move.use(MoveId.ENCORE, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
await game.toNextTurn();
console.log(enemy1.getLastXMoves(-1));
// Encore locked into Tackle, replacing the enemy's Growl with another Tackle
expect(enemy1.getTag(BattlerTagType.ENCORE)?.["moveId"]).toBe(MoveId.TACKLE);
expect(enemy1).toHaveUsedMove({ move: MoveId.TACKLE, useMode: MoveUseMode.NORMAL });
});
it("should boost stomping tantrum after a failed bounce", async () => {
game.override.ability(AbilityId.INSOMNIA);
await game.classicMode.startBattle([SpeciesId.BULBASAUR]);
const enemy = game.field.getEnemyPokemon();
const powerSpy = vi.spyOn(allMoves[MoveId.STOMPING_TANTRUM], "calculateBattlePower");
// Yawn gets reflected back onto us, failing due to Insomnia
game.move.use(MoveId.YAWN);
await game.move.forceEnemyMove(MoveId.MAGIC_COAT);
await game.toNextTurn();
expect(enemy).toHaveUsedMove({ move: MoveId.YAWN, result: MoveResult.FAIL, useMode: MoveUseMode.REFLECTED });
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.STOMPING_TANTRUM);
await game.toNextTurn();
expect(powerSpy).toHaveReturnedWith(150);
});
it("should respect immunities when bouncing a move", async () => {
vi.spyOn(allMoves[MoveId.THUNDER_WAVE], "accuracy", "get").mockReturnValue(100);
game.override.ability(AbilityId.SOUNDPROOF);
await game.classicMode.startBattle([SpeciesId.PHANPY]);
// Turn 1 - thunder wave immunity test
game.move.use(MoveId.THUNDER_WAVE);
await game.toEndOfTurn();
expect(game.field.getPlayerPokemon().status).toBeUndefined();
// Turn 2 - soundproof immunity test
game.move.use(MoveId.GROWL);
await game.toEndOfTurn();
expect(game.field.getPlayerPokemon()).toHaveStatStage(Stat.ATK, 0);
});
it("should ignore the original move's accuracy and use the user's accuracy", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const magikarp = game.field.getPlayerPokemon();
const feebas = game.field.getEnemyPokemon();
const karpMissSpy = vi.spyOn(magikarp, "getAccuracyMultiplier").mockReturnValue(0);
// Turn 1: Force a miss on initial move
game.move.use(MoveId.SPORE);
await game.phaseInterceptor.to("MoveEndPhase");
await game.toEndOfTurn();
// todo change once matchers fixed
expect(magikarp.status?.effect).toBe(StatusEffect.SLEEP);
magikarp.clearStatus(false, false);
karpMissSpy.mockRestore();
vi.spyOn(feebas, "getAccuracyMultiplier").mockReturnValue(0);
// Turn 2: Force a miss on Feebas' reflected move
game.move.use(MoveId.SPORE);
await game.toEndOfTurn();
expect(magikarp.status?.effect).toBeFalsy();
});
});
describe("Magic Bounce", () => {
beforeEach(() => {
game.override.enemyAbility(AbilityId.MAGIC_BOUNCE).enemyMoveset(MoveId.SPLASH);
});
// TODO: Change post speed order rework to check the FASTER pokemon's ability
it("should only apply the leftmost available target's magic bounce when bouncing field-targeted moves in doubles", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
const [enemy1, enemy2] = game.scene.getEnemyField();
// set speed to different values just in case logic erroneously checks for speed order
enemy1.setStat(Stat.SPD, enemy2.getStat(Stat.SPD) + 1);
// turn 1
game.move.use(MoveId.SPIKES, 0);
game.move.use(MoveId.TRICK_ROOM, 1);
await game.toNextTurn();
// TODO: Replace this with `expect(game).toHaveArenaTag({tagType: ArenaTagType.SPIKES, side: ArenaTagSide.PLAYER, sourceId: enemy1.id, layers: 1})
const tag = game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!;
expect(tag).toBeDefined();
expect(tag.getSourcePokemon()).toBe(enemy1);
expect(tag["layers"]).toBe(1);
game.scene.arena.removeTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER, true);
// turn 2
game.move.use(MoveId.SPIKES, 0);
game.move.use(MoveId.TRICK_ROOM, 1);
await game.toEndOfTurn();
// TODO: Replace this with `expect(game).toHaveArenaTag({tagType: ArenaTagType.SPIKES, side: ArenaTagSide.PLAYER, sourceId: enemy1.id, layers: 1})
expect(
game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)?.getSourcePokemon()?.getBattlerIndex(),
).toBe(BattlerIndex.ENEMY);
});
it("should not bounce back status moves against semi-invulnerable Pokemon, even with No Guard", async () => {
await game.classicMode.startBattle([SpeciesId.BULBASAUR]);
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
// Turn 1: use charm while enemy is airborne; misses
game.move.use(MoveId.CHARM);
await game.move.forceEnemyMove(MoveId.FLY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(player).toHaveStatStage(Stat.ATK, 0);
expect(enemy).toHaveStatStage(Stat.ATK, 0);
// Turn 2: Use Charm through No Guard; should not be reflected
game.field.mockAbility(player, AbilityId.NO_GUARD);
game.move.use(MoveId.CHARM);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(player).toHaveStatStage(Stat.ATK, 0);
expect(enemy).toHaveStatStage(Stat.ATK, -2);
});
it("should be overridden by Magic Coat without stacking", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const karp = game.field.getPlayerPokemon();
game.move.use(MoveId.GROWL);
await game.move.forceEnemyMove(MoveId.MAGIC_COAT);
await game.toEndOfTurn();
expect(karp).toHaveStatStage(Stat.ATK, -1);
expect(game.field.getEnemyPokemon()).not.toHaveAbilityApplied(AbilityId.MAGIC_BOUNCE);
});
it("should bounce spikes even when the target is protected", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.SPIKES);
await game.move.forceEnemyMove(MoveId.PROTECT);
await game.toEndOfTurn();
// TODO: Replace this with `expect(game).toHaveArenaTag({tagType: ArenaTagType.SPIKES, side: ArenaTagSide.PLAYER, layers: 1})
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
});
it("should not break subsequent multi-strike moves", async () => {
await game.classicMode.startBattle([SpeciesId.PALKIA]);
game.move.use(MoveId.GROWL);
await game.move.forceEnemyMove(MoveId.SURGING_STRIKES);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
const enemy = game.field.getEnemyPokemon();
expect(enemy.turnData.hitCount).toBe(3);
});
});
describe("Magic Coat", () => {
it("should fail if the user goes last in the turn", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.PROTECT);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should fail if called again in the same turn from Instruct", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.INSTRUCT);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should not reflect moves used on the next turn", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
// turn 1
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.MAGIC_COAT);
await game.toNextTurn();
// turn 2
game.move.use(MoveId.GROWL);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, -1);
});
it("should still bounce back a move from a mold breaker user", async () => {
game.override.ability(AbilityId.MOLD_BREAKER).moveset([MoveId.GROWL]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.GROWL);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon()).toHaveStatStage(Stat.ATK, 0);
expect(game.field.getPlayerPokemon()).toHaveStatStage(Stat.ATK, -1);
});
it("should only bounce spikes back once when both targets use magic coat in doubles", async () => {
game.override.battleStyle("double");
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.SPIKES);
await game.toEndOfTurn();
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)).toBeUndefined();
});
});
});

View File

@ -1,285 +0,0 @@
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagSide } from "#enums/arena-tag-side";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { Stat } from "#enums/stat";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Magic Coat", () => {
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)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.MAGIC_COAT);
});
it("should fail if the user goes last in the turn", async () => {
game.override.moveset([MoveId.PROTECT]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.PROTECT);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should fail if called again in the same turn due to moves like instruct", async () => {
game.override.moveset([MoveId.INSTRUCT]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.INSTRUCT);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getLastXMoves()[0].result).toBe(MoveResult.FAIL);
});
it("should not reflect moves used on the next turn", async () => {
game.override.moveset([MoveId.GROWL, MoveId.SPLASH]).enemyMoveset([MoveId.MAGIC_COAT, MoveId.SPLASH]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
// turn 1
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.MAGIC_COAT);
await game.toNextTurn();
// turn 2
game.move.select(MoveId.GROWL);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should reflect basic status moves", async () => {
game.override.moveset([MoveId.GROWL]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should individually bounce back multi-target moves when used by both targets in doubles", async () => {
game.override.battleStyle("double").moveset([MoveId.GROWL, MoveId.SPLASH]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
game.move.select(MoveId.GROWL, 0);
game.move.select(MoveId.SPLASH, 1);
await game.phaseInterceptor.to("BerryPhase");
const user = game.scene.getPlayerField()[0];
expect(user.getStatStage(Stat.ATK)).toBe(-2);
});
it("should bounce back a spread status move against both pokemon", async () => {
game.override
.battleStyle("double")
.moveset([MoveId.GROWL, MoveId.SPLASH])
.enemyMoveset([MoveId.SPLASH, MoveId.MAGIC_COAT]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
game.move.select(MoveId.GROWL, 0);
game.move.select(MoveId.SPLASH, 1);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.MAGIC_COAT);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getPlayerField().every(p => p.getStatStage(Stat.ATK) === -1)).toBeTruthy();
});
it("should still bounce back a move that would otherwise fail", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.field.getEnemyPokemon().setStatStage(Stat.ATK, -6);
game.override.moveset([MoveId.GROWL]);
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should not bounce back a move that was just bounced", async () => {
game.override
.battleStyle("double")
.ability(AbilityId.MAGIC_BOUNCE)
.moveset([MoveId.GROWL, MoveId.MAGIC_COAT])
.enemyMoveset([MoveId.SPLASH, MoveId.MAGIC_COAT]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MAGIKARP]);
game.move.select(MoveId.MAGIC_COAT, 0);
game.move.select(MoveId.GROWL, 1);
await game.move.selectEnemyMove(MoveId.MAGIC_COAT);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.getEnemyField()[0].getStatStage(Stat.ATK)).toBe(0);
});
// todo while Mirror Armor is not implemented
it.todo("should receive the stat change after reflecting a move back to a mirror armor user", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should still bounce back a move from a mold breaker user", async () => {
game.override.ability(AbilityId.MOLD_BREAKER).moveset([MoveId.GROWL]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getStatStage(Stat.ATK)).toBe(0);
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(-1);
});
it("should only bounce spikes back once when both targets use magic coat in doubles", async () => {
game.override.battleStyle("double").moveset([MoveId.SPIKES]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.SPIKES);
await game.phaseInterceptor.to("BerryPhase");
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.PLAYER)!["layers"]).toBe(1);
expect(game.scene.arena.getTagOnSide(ArenaTagType.SPIKES, ArenaTagSide.ENEMY)).toBeUndefined();
});
it("should not bounce back curse", async () => {
game.override.moveset([MoveId.CURSE]);
await game.classicMode.startBattle([SpeciesId.GASTLY]);
game.move.select(MoveId.CURSE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getEnemyPokemon().getTag(BattlerTagType.CURSED)).toBeDefined();
});
// TODO: encore is failing if the last move was virtual.
it.todo("should not cause the bounced move to count for encore", async () => {
game.override
.moveset([MoveId.GROWL, MoveId.ENCORE])
.enemyMoveset([MoveId.MAGIC_COAT, MoveId.TACKLE])
.enemyAbility(AbilityId.MAGIC_BOUNCE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyPokemon = game.field.getEnemyPokemon();
// turn 1
game.move.select(MoveId.GROWL);
await game.move.selectEnemyMove(MoveId.MAGIC_COAT);
await game.toNextTurn();
// turn 2
game.move.select(MoveId.ENCORE);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase");
expect(enemyPokemon.getTag(BattlerTagType.ENCORE)!["moveId"]).toBe(MoveId.TACKLE);
expect(enemyPokemon.getLastXMoves()[0].move).toBe(MoveId.TACKLE);
});
// TODO: stomping tantrum should consider moves that were bounced.
it.todo("should cause stomping tantrum to double in power when the last move was bounced", async () => {
game.override.battleStyle("single").moveset([MoveId.STOMPING_TANTRUM, MoveId.CHARM]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const stomping_tantrum = allMoves[MoveId.STOMPING_TANTRUM];
vi.spyOn(stomping_tantrum, "calculateBattlePower");
game.move.select(MoveId.CHARM);
await game.toNextTurn();
game.move.select(MoveId.STOMPING_TANTRUM);
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(150);
});
// TODO: stomping tantrum should consider moves that were bounced.
it.todo(
"should properly cause the enemy's stomping tantrum to be doubled in power after bouncing and failing",
async () => {
game.override.enemyMoveset([MoveId.STOMPING_TANTRUM, MoveId.SPLASH, MoveId.CHARM]);
await game.classicMode.startBattle([SpeciesId.BULBASAUR]);
const stomping_tantrum = allMoves[MoveId.STOMPING_TANTRUM];
const enemy = game.field.getEnemyPokemon();
vi.spyOn(stomping_tantrum, "calculateBattlePower");
game.move.select(MoveId.SPORE);
await game.move.selectEnemyMove(MoveId.CHARM);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemy.getLastXMoves(1)[0].result).toBe("success");
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(75);
await game.toNextTurn();
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(stomping_tantrum.calculateBattlePower).toHaveReturnedWith(75);
},
);
it("should respect immunities when bouncing a move", async () => {
vi.spyOn(allMoves[MoveId.THUNDER_WAVE], "accuracy", "get").mockReturnValue(100);
game.override.moveset([MoveId.THUNDER_WAVE, MoveId.GROWL]).ability(AbilityId.SOUNDPROOF);
await game.classicMode.startBattle([SpeciesId.PHANPY]);
// Turn 1 - thunder wave immunity test
game.move.select(MoveId.THUNDER_WAVE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().status).toBeUndefined();
// Turn 2 - soundproof immunity test
game.move.select(MoveId.GROWL);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getStatStage(Stat.ATK)).toBe(0);
});
it("should bounce back a move before the accuracy check", async () => {
game.override.moveset([MoveId.SPORE]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const attacker = game.field.getPlayerPokemon();
vi.spyOn(attacker, "getAccuracyMultiplier").mockReturnValue(0.0);
game.move.select(MoveId.SPORE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().status?.effect).toBe(StatusEffect.SLEEP);
});
it("should take the accuracy of the magic bounce user into account", async () => {
game.override.moveset([MoveId.SPORE]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const opponent = game.field.getEnemyPokemon();
vi.spyOn(opponent, "getAccuracyMultiplier").mockReturnValue(0);
game.move.select(MoveId.SPORE);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().status).toBeUndefined();
});
});

View File

@ -371,9 +371,13 @@ export class GameManager {
console.log("==================[New Turn]==================");
}
/** Transition to the {@linkcode TurnEndPhase | end of the current turn}. */
async toEndOfTurn() {
await this.phaseInterceptor.to("TurnEndPhase");
/**
* Transition to the {@linkcode TurnEndPhase | end of the current turn}.
* @param runTarget - Whether or not to run the {@linkcode TurnEndPhase}; default `true`
* @returns A Promise that resolves once the turn has ended.
*/
async toEndOfTurn(runTarget = true): Promise<void> {
await this.phaseInterceptor.to("TurnEndPhase", runTarget);
console.log("==================[End of Turn]==================");
}
@ -532,14 +536,16 @@ export class GameManager {
}
/**
* Intercepts `TurnStartPhase` and mocks {@linkcode TurnStartPhase.getSpeedOrder}'s return value.
* Intercepts `TurnStartPhase` and mocks {@linkcode TurnStartPhase.getSpeedOrder}'s return value. \
* Used to manually modify Pokemon turn order.
* Note: This *DOES NOT* account for priority.
* @param order - The turn order to set as an array of {@linkcode BattlerIndex}es.
*
* @param order - The turn order to set, as an array of {@linkcode BattlerIndex}es
* @example
* ```ts
* await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2]);
* ```
* @remarks
* This *does not* account for priority and will override Trick Room's effect.
*/
async setTurnOrder(order: BattlerIndex[]): Promise<void> {
await this.phaseInterceptor.to(TurnStartPhase, false);