mirror of
https://github.com/pagefaultgames/pokerogue.git
synced 2025-08-07 07:59:26 +02:00
Merge branch 'beta' into rest
This commit is contained in:
commit
1cb1797f3b
@ -884,8 +884,8 @@ export default class BattleScene extends SceneBase {
|
||||
}
|
||||
|
||||
// store info toggles to be accessible by the ui
|
||||
addInfoToggle(infoToggle: InfoToggle): void {
|
||||
this.infoToggles.push(infoToggle);
|
||||
addInfoToggle(...infoToggles: InfoToggle[]): void {
|
||||
this.infoToggles.push(...infoToggles);
|
||||
}
|
||||
|
||||
// return the stored info toggles; used by ui-inputs
|
||||
|
@ -1610,37 +1610,38 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr {
|
||||
}
|
||||
}
|
||||
|
||||
/** Ability attribute for changing a pokemon's type before using a move */
|
||||
/**
|
||||
* Attribute to change the user's type to that of the move currently being executed.
|
||||
* Used by {@linkcode AbilityId.PROTEAN} and {@linkcode AbilityId.LIBERO}.
|
||||
*/
|
||||
export class PokemonTypeChangeAbAttr extends PreAttackAbAttr {
|
||||
private moveType: PokemonType;
|
||||
|
||||
private moveType: PokemonType = PokemonType.UNKNOWN;
|
||||
constructor() {
|
||||
super(true);
|
||||
}
|
||||
|
||||
override canApply({ move, pokemon }: AugmentMoveInteractionAbAttrParams): boolean {
|
||||
if (
|
||||
!pokemon.isTerastallized &&
|
||||
move.id !== MoveId.STRUGGLE &&
|
||||
/**
|
||||
pokemon.isTerastallized ||
|
||||
move.id === MoveId.STRUGGLE ||
|
||||
/*
|
||||
* Skip moves that call other moves because these moves generate a following move that will trigger this ability attribute
|
||||
* @see {@link https://bulbapedia.bulbagarden.net/wiki/Category:Moves_that_call_other_moves}
|
||||
* See: https://bulbapedia.bulbagarden.net/wiki/Category:Moves_that_call_other_moves
|
||||
*/
|
||||
!move.findAttr(
|
||||
attr =>
|
||||
attr.is("RandomMovesetMoveAttr") ||
|
||||
attr.is("RandomMoveAttr") ||
|
||||
attr.is("NaturePowerAttr") ||
|
||||
attr.is("CopyMoveAttr"),
|
||||
)
|
||||
move.hasAttr("CallMoveAttr") ||
|
||||
move.hasAttr("NaturePowerAttr") // TODO: remove this line when nature power is made to extend from `CallMoveAttr`
|
||||
) {
|
||||
const moveType = pokemon.getMoveType(move);
|
||||
if (pokemon.getTypes().some(t => t !== moveType)) {
|
||||
this.moveType = moveType;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
// Skip changing type if we're already of the given type as-is
|
||||
const moveType = pokemon.getMoveType(move);
|
||||
if (pokemon.getTypes().every(t => t === moveType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.moveType = moveType;
|
||||
return true;
|
||||
}
|
||||
|
||||
override apply({ simulated, pokemon, move }: AugmentMoveInteractionAbAttrParams): void {
|
||||
@ -7190,8 +7191,10 @@ export function initAbilities() {
|
||||
new Ability(AbilityId.CHEEK_POUCH, 6)
|
||||
.attr(HealFromBerryUseAbAttr, 1 / 3),
|
||||
new Ability(AbilityId.PROTEAN, 6)
|
||||
.attr(PokemonTypeChangeAbAttr),
|
||||
//.condition((p) => !p.summonData.abilitiesApplied.includes(AbilityId.PROTEAN)), //Gen 9 Implementation
|
||||
.attr(PokemonTypeChangeAbAttr)
|
||||
// .condition((p) => !p.summonData.abilitiesApplied.includes(Abilities.PROTEAN)) //Gen 9 Implementation
|
||||
// TODO: needs testing on interaction with weather blockage
|
||||
.edgeCase(),
|
||||
new Ability(AbilityId.FUR_COAT, 6)
|
||||
.attr(ReceivedMoveDamageMultiplierAbAttr, (_target, _user, move) => move.category === MoveCategory.PHYSICAL, 0.5)
|
||||
.ignorable(),
|
||||
@ -7443,8 +7446,10 @@ export function initAbilities() {
|
||||
new Ability(AbilityId.DAUNTLESS_SHIELD, 8)
|
||||
.attr(PostSummonStatStageChangeAbAttr, [ Stat.DEF ], 1, true),
|
||||
new Ability(AbilityId.LIBERO, 8)
|
||||
.attr(PokemonTypeChangeAbAttr),
|
||||
//.condition((p) => !p.summonData.abilitiesApplied.includes(AbilityId.LIBERO)), //Gen 9 Implementation
|
||||
.attr(PokemonTypeChangeAbAttr)
|
||||
//.condition((p) => !p.summonData.abilitiesApplied.includes(Abilities.LIBERO)), //Gen 9 Implementation
|
||||
// TODO: needs testing on interaction with weather blockage
|
||||
.edgeCase(),
|
||||
new Ability(AbilityId.BALL_FETCH, 8)
|
||||
.attr(FetchBallAbAttr)
|
||||
.condition(getOncePerBattleCondition(AbilityId.BALL_FETCH)),
|
||||
|
@ -681,13 +681,7 @@ export default abstract class Move implements Localizable {
|
||||
* @returns boolean: false if any of the apply()'s return false, else true
|
||||
*/
|
||||
applyConditions(user: Pokemon, target: Pokemon, move: Move): boolean {
|
||||
for (const condition of this.conditions) {
|
||||
if (!condition.apply(user, target, move)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return this.conditions.every(cond => cond.apply(user, target, move));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -4160,30 +4154,6 @@ export class OpponentHighHpPowerAttr extends VariablePowerAttr {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute to double this move's power if the target hasn't acted yet in the current turn.
|
||||
* Used by {@linkcode Moves.BOLT_BEAK} and {@linkcode Moves.FISHIOUS_REND}
|
||||
*/
|
||||
export class FirstAttackDoublePowerAttr extends VariablePowerAttr {
|
||||
/**
|
||||
* Double this move's power if the user is acting before the target.
|
||||
* @param user - Unused
|
||||
* @param target - The {@linkcode Pokemon} being targeted by this move
|
||||
* @param move - Unused
|
||||
* @param args `[0]` - A {@linkcode NumberHolder} containing move base power
|
||||
* @returns Whether the attribute was successfully applied
|
||||
*/
|
||||
apply(_user: Pokemon, target: Pokemon, move: Move, args: [NumberHolder]): boolean {
|
||||
if (target.turnData.acted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
args[0].value *= 2;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class TurnDamagedDoublePowerAttr extends VariablePowerAttr {
|
||||
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
|
||||
if (user.turnData.attacksReceived.find(r => r.damage && r.sourceId === target.id)) {
|
||||
@ -8367,7 +8337,6 @@ const MoveAttrs = Object.freeze({
|
||||
CompareWeightPowerAttr,
|
||||
HpPowerAttr,
|
||||
OpponentHighHpPowerAttr,
|
||||
FirstAttackDoublePowerAttr,
|
||||
TurnDamagedDoublePowerAttr,
|
||||
MagnitudePowerAttr,
|
||||
AntiSunlightPowerDecreaseAttr,
|
||||
@ -9670,7 +9639,8 @@ export function initMoves() {
|
||||
new AttackMove(MoveId.CLOSE_COMBAT, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 4)
|
||||
.attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], -1, true),
|
||||
new AttackMove(MoveId.PAYBACK, PokemonType.DARK, MoveCategory.PHYSICAL, 50, 100, 10, -1, 0, 4)
|
||||
.attr(MovePowerMultiplierAttr, (user, target, move) => target.getLastXMoves(1).find(m => m.turn === globalScene.currentBattle.turn) || globalScene.currentBattle.turnCommands[target.getBattlerIndex()]?.command === Command.BALL ? 2 : 1),
|
||||
// Payback boosts power on item use
|
||||
.attr(MovePowerMultiplierAttr, (_user, target) => target.turnData.acted || globalScene.currentBattle.turnCommands[target.getBattlerIndex()]?.command === Command.BALL ? 2 : 1),
|
||||
new AttackMove(MoveId.ASSURANCE, PokemonType.DARK, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 4)
|
||||
.attr(MovePowerMultiplierAttr, (user, target, move) => target.turnData.damageTaken > 0 ? 2 : 1),
|
||||
new StatusMove(MoveId.EMBARGO, PokemonType.DARK, 100, 15, -1, 0, 4)
|
||||
@ -10876,9 +10846,9 @@ export function initMoves() {
|
||||
.condition(failIfGhostTypeCondition)
|
||||
.attr(AddBattlerTagAttr, BattlerTagType.OCTOLOCK, false, true, 1),
|
||||
new AttackMove(MoveId.BOLT_BEAK, PokemonType.ELECTRIC, MoveCategory.PHYSICAL, 85, 100, 10, -1, 0, 8)
|
||||
.attr(FirstAttackDoublePowerAttr),
|
||||
.attr(MovePowerMultiplierAttr, (_user, target) => target.turnData.acted ? 1 : 2),
|
||||
new AttackMove(MoveId.FISHIOUS_REND, PokemonType.WATER, MoveCategory.PHYSICAL, 85, 100, 10, -1, 0, 8)
|
||||
.attr(FirstAttackDoublePowerAttr)
|
||||
.attr(MovePowerMultiplierAttr, (_user, target) => target.turnData.acted ? 1 : 2)
|
||||
.bitingMove(),
|
||||
new StatusMove(MoveId.COURT_CHANGE, PokemonType.NORMAL, 100, 10, -1, 0, 8)
|
||||
.attr(SwapArenaTagsAttr, [ ArenaTagType.AURORA_VEIL, ArenaTagType.LIGHT_SCREEN, ArenaTagType.MIST, ArenaTagType.REFLECT, ArenaTagType.SPIKES, ArenaTagType.STEALTH_ROCK, ArenaTagType.STICKY_WEB, ArenaTagType.TAILWIND, ArenaTagType.TOXIC_SPIKES ]),
|
||||
|
@ -25,6 +25,7 @@ import i18next from "i18next";
|
||||
import { globalScene } from "#app/global-scene";
|
||||
import { Gender } from "#app/data/gender";
|
||||
|
||||
// TODO: Refactor and split up to allow for overriding capture chance
|
||||
export class AttemptCapturePhase extends PokemonPhase {
|
||||
public readonly phaseName = "AttemptCapturePhase";
|
||||
private pokeballType: PokeballType;
|
||||
|
@ -6,7 +6,6 @@ import { CommonAnim } from "#enums/move-anims-common";
|
||||
import { CenterOfAttentionTag } from "#app/data/battler-tags";
|
||||
import { BattlerTagLapseType } from "#enums/battler-tag-lapse-type";
|
||||
import { applyMoveAttrs } from "#app/data/moves/apply-attrs";
|
||||
import { allMoves } from "#app/data/data-lists";
|
||||
import { MoveFlags } from "#enums/MoveFlags";
|
||||
import { SpeciesFormChangePreMoveTrigger } from "#app/data/pokemon-forms/form-change-triggers";
|
||||
import { getStatusEffectActivationText, getStatusEffectHealText } from "#app/data/status-effect";
|
||||
@ -36,11 +35,11 @@ export class MovePhase extends BattlePhase {
|
||||
protected _move: PokemonMove;
|
||||
protected _targets: BattlerIndex[];
|
||||
public readonly useMode: MoveUseMode; // Made public for quash
|
||||
/** Whether the current move is forced last (used for Quash). */
|
||||
protected forcedLast: boolean;
|
||||
|
||||
/** Whether the current move should fail but still use PP */
|
||||
/** Whether the current move should fail but still use PP. */
|
||||
protected failed = false;
|
||||
/** Whether the current move should cancel and retain PP */
|
||||
/** Whether the current move should fail and retain PP. */
|
||||
protected cancelled = false;
|
||||
|
||||
public get pokemon(): Pokemon {
|
||||
@ -165,6 +164,7 @@ export class MovePhase extends BattlePhase {
|
||||
this.resolveFinalPreMoveCancellationChecks();
|
||||
}
|
||||
|
||||
// Cancel, charge or use the move as applicable.
|
||||
if (this.cancelled || this.failed) {
|
||||
this.handlePreMoveFailures();
|
||||
} else if (this.move.getMove().isChargingMove() && !this.pokemon.getTag(BattlerTagType.CHARGING)) {
|
||||
@ -282,8 +282,7 @@ export class MovePhase extends BattlePhase {
|
||||
protected lapsePreMoveAndMoveTags(): void {
|
||||
this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE);
|
||||
|
||||
// TODO: does this intentionally happen before the no targets/MoveId.NONE on queue cancellation case is checked?
|
||||
// (In other words, check if truant can proc on a move w/o targets)
|
||||
// This intentionally happens before moves without targets are cancelled (Truant takes priority over lack of targets)
|
||||
if (!isIgnoreStatus(this.useMode) && this.canMove() && !this.cancelled) {
|
||||
this.pokemon.lapseTags(BattlerTagLapseType.MOVE);
|
||||
}
|
||||
@ -297,44 +296,27 @@ export class MovePhase extends BattlePhase {
|
||||
// form changes happen even before we know that the move wll execute.
|
||||
globalScene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger);
|
||||
|
||||
const isDelayedAttack = move.hasAttr("DelayedAttackAttr");
|
||||
if (isDelayedAttack) {
|
||||
// Check the player side arena if future sight is active
|
||||
const futureSightTags = globalScene.arena.findTags(t => t.tagType === ArenaTagType.FUTURE_SIGHT);
|
||||
const doomDesireTags = globalScene.arena.findTags(t => t.tagType === ArenaTagType.DOOM_DESIRE);
|
||||
let fail = false;
|
||||
// Check the player side arena if another delayed attack is active and hitting the same slot.
|
||||
if (move.hasAttr("DelayedAttackAttr")) {
|
||||
const currentTargetIndex = targets[0].getBattlerIndex();
|
||||
for (const tag of futureSightTags) {
|
||||
if ((tag as DelayedAttackTag).targetIndex === currentTargetIndex) {
|
||||
fail = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const tag of doomDesireTags) {
|
||||
if ((tag as DelayedAttackTag).targetIndex === currentTargetIndex) {
|
||||
fail = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fail) {
|
||||
this.showMoveText();
|
||||
this.showFailedText();
|
||||
this.end();
|
||||
const delayedAttackHittingSameSlot = globalScene.arena.tags.some(
|
||||
tag =>
|
||||
(tag.tagType === ArenaTagType.FUTURE_SIGHT || tag.tagType === ArenaTagType.DOOM_DESIRE) &&
|
||||
(tag as DelayedAttackTag).targetIndex === currentTargetIndex,
|
||||
);
|
||||
|
||||
if (delayedAttackHittingSameSlot) {
|
||||
this.failMove(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let success = true;
|
||||
// Check if there are any attributes that can interrupt the move, overriding the fail message.
|
||||
for (const move of this.move.getMove().getAttrs("PreUseInterruptAttr")) {
|
||||
if (move.apply(this.pokemon, targets[0], this.move.getMove())) {
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
this.showMoveText();
|
||||
// Check if the move has any attributes that can interrupt its own use **before** displaying text.
|
||||
// TODO: This should not rely on direct return values
|
||||
let failed = move.getAttrs("PreUseInterruptAttr").some(attr => attr.apply(this.pokemon, targets[0], move));
|
||||
if (failed) {
|
||||
this.failMove(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear out any two turn moves once they've been used.
|
||||
@ -342,6 +324,7 @@ export class MovePhase extends BattlePhase {
|
||||
// Move queues should be handled by the calling `CommandPhase` or a manager for it
|
||||
// @ts-expect-error - useMode is readonly and shouldn't normally be assigned to
|
||||
this.useMode = moveQueue.shift()?.useMode ?? this.useMode;
|
||||
|
||||
if (this.pokemon.getTag(BattlerTagType.CHARGING)?.sourceMove === this.move.moveId) {
|
||||
this.pokemon.lapseTag(BattlerTagType.CHARGING);
|
||||
}
|
||||
@ -349,136 +332,145 @@ export class MovePhase extends BattlePhase {
|
||||
if (!isIgnorePP(this.useMode)) {
|
||||
// "commit" to using the move, deducting PP.
|
||||
const ppUsed = 1 + this.getPpIncreaseFromPressure(targets);
|
||||
|
||||
this.move.usePp(ppUsed);
|
||||
globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, move, this.move.ppUsed));
|
||||
globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon.id, move, this.move.ppUsed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the move is successful (meaning that its damage/effects can be attempted)
|
||||
* by checking that all of the following are true:
|
||||
* - Conditional attributes of the move are all met
|
||||
* - The target's `ForceSwitchOutImmunityAbAttr` is not triggered (see {@linkcode Move.prototype.applyConditions})
|
||||
* - Weather does not block the move
|
||||
* - Terrain does not block the move
|
||||
*
|
||||
* TODO: These steps are straightforward, but the implementation below is extremely convoluted.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Move conditions assume the move has a single target
|
||||
* TODO: is this sustainable?
|
||||
*/
|
||||
let failedDueToTerrain = false;
|
||||
let failedDueToWeather = false;
|
||||
if (success) {
|
||||
const passesConditions = move.applyConditions(this.pokemon, targets[0], move);
|
||||
failedDueToWeather = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move);
|
||||
failedDueToTerrain = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move);
|
||||
success = passesConditions && !failedDueToWeather && !failedDueToTerrain;
|
||||
const failsConditions = !move.applyConditions(this.pokemon, targets[0], move);
|
||||
const failedDueToWeather = globalScene.arena.isMoveWeatherCancelled(this.pokemon, move);
|
||||
const failedDueToTerrain = globalScene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move);
|
||||
failed ||= failsConditions || failedDueToWeather || failedDueToTerrain;
|
||||
|
||||
if (failed) {
|
||||
this.failMove(true, failedDueToWeather, failedDueToTerrain);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the battle's "last move" pointer, unless we're currently mimicking a move.
|
||||
if (!allMoves[this.move.moveId].hasAttr("CopyMoveAttr")) {
|
||||
// The last move used is unaffected by moves that fail
|
||||
if (success) {
|
||||
globalScene.currentBattle.lastMove = this.move.moveId;
|
||||
}
|
||||
this.executeMove();
|
||||
}
|
||||
|
||||
/** Execute the current move and apply its effects. */
|
||||
private executeMove() {
|
||||
const move = this.move.getMove();
|
||||
const targets = this.getActiveTargetPokemon();
|
||||
|
||||
// Update the battle's "last move" pointer unless we're currently mimicking a move or triggering Dancer.
|
||||
if (!move.hasAttr("CopyMoveAttr") && !isReflected(this.useMode)) {
|
||||
globalScene.currentBattle.lastMove = move.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the move has not failed, trigger ability-based user type changes and then execute it.
|
||||
*
|
||||
* Notably, Roar, Whirlwind, Trick-or-Treat, and Forest's Curse will trigger these type changes even
|
||||
* if the move fails.
|
||||
*/
|
||||
if (success) {
|
||||
const move = this.move.getMove();
|
||||
// TODO: Investigate whether PokemonTypeChangeAbAttr can drop the "opponent" parameter
|
||||
applyAbAttrs("PokemonTypeChangeAbAttr", { pokemon: this.pokemon, move, opponent: targets[0] });
|
||||
globalScene.phaseManager.unshiftNew(
|
||||
"MoveEffectPhase",
|
||||
this.pokemon.getBattlerIndex(),
|
||||
this.targets,
|
||||
move,
|
||||
this.useMode,
|
||||
);
|
||||
} else {
|
||||
if ([MoveId.ROAR, MoveId.WHIRLWIND, MoveId.TRICK_OR_TREAT, MoveId.FORESTS_CURSE].includes(this.move.moveId)) {
|
||||
applyAbAttrs("PokemonTypeChangeAbAttr", {
|
||||
pokemon: this.pokemon,
|
||||
move: this.move.getMove(),
|
||||
opponent: targets[0],
|
||||
});
|
||||
}
|
||||
|
||||
this.pokemon.pushMoveHistory({
|
||||
move: this.move.moveId,
|
||||
targets: this.targets,
|
||||
result: MoveResult.FAIL,
|
||||
useMode: this.useMode,
|
||||
});
|
||||
|
||||
const failureMessage = move.getFailedText(this.pokemon, targets[0], move);
|
||||
let failedText: string | undefined;
|
||||
if (failureMessage) {
|
||||
failedText = failureMessage;
|
||||
} else if (failedDueToTerrain) {
|
||||
failedText = getTerrainBlockMessage(targets[0], globalScene.arena.getTerrainType());
|
||||
} else if (failedDueToWeather) {
|
||||
failedText = getWeatherBlockMessage(globalScene.arena.getWeatherType());
|
||||
}
|
||||
|
||||
this.showFailedText(failedText);
|
||||
|
||||
// Remove the user from its semi-invulnerable state (if applicable)
|
||||
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
|
||||
}
|
||||
// Trigger ability-based user type changes, display move text and then execute move effects.
|
||||
// TODO: Investigate whether PokemonTypeChangeAbAttr can drop the "opponent" parameter
|
||||
applyAbAttrs("PokemonTypeChangeAbAttr", { pokemon: this.pokemon, move, opponent: targets[0] });
|
||||
this.showMoveText();
|
||||
globalScene.phaseManager.unshiftNew(
|
||||
"MoveEffectPhase",
|
||||
this.pokemon.getBattlerIndex(),
|
||||
this.targets,
|
||||
move,
|
||||
this.useMode,
|
||||
);
|
||||
|
||||
// Handle Dancer, which triggers immediately after a move is used (rather than waiting on `this.end()`).
|
||||
// Note the MoveUseMode check here prevents an infinite Dancer loop.
|
||||
// TODO: This needs to go at the end of `MoveEffectPhase` to check move results
|
||||
const dancerModes: MoveUseMode[] = [MoveUseMode.INDIRECT, MoveUseMode.REFLECTED] as const;
|
||||
if (this.move.getMove().hasFlag(MoveFlags.DANCE_MOVE) && !dancerModes.includes(this.useMode)) {
|
||||
// TODO: Fix in dancer PR to move to MEP for hit checks
|
||||
globalScene.getField(true).forEach(pokemon => {
|
||||
applyAbAttrs("PostMoveUsedAbAttr", { pokemon, move: this.move, source: this.pokemon, targets: this.targets });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Queues a {@linkcode MoveChargePhase} for this phase's invoked move. */
|
||||
/**
|
||||
* Fail the move currently being used.
|
||||
* Handles failure messages, pushing to move history, etc.
|
||||
* @param showText - Whether to show move text when failing the move.
|
||||
* @param failedDueToWeather - Whether the move failed due to weather (default `false`)
|
||||
* @param failedDueToTerrain - Whether the move failed due to terrain (default `false`)
|
||||
*/
|
||||
protected failMove(showText: boolean, failedDueToWeather = false, failedDueToTerrain = false) {
|
||||
const move = this.move.getMove();
|
||||
const targets = this.getActiveTargetPokemon();
|
||||
|
||||
// DO NOT CHANGE THE ORDER OF OPERATIONS HERE!
|
||||
// Protean is supposed to trigger its effects first, _then_ move text is displayed,
|
||||
// _then_ any blockage messages are shown.
|
||||
|
||||
// Roar, Whirlwind, Trick-or-Treat, and Forest's Curse will trigger Protean/Libero
|
||||
// even on failure, as will all moves blocked by terrain.
|
||||
// TODO: Verify if this also applies to primal weather failures
|
||||
if (
|
||||
failedDueToTerrain ||
|
||||
[MoveId.ROAR, MoveId.WHIRLWIND, MoveId.TRICK_OR_TREAT, MoveId.FORESTS_CURSE].includes(this.move.moveId)
|
||||
) {
|
||||
applyAbAttrs("PokemonTypeChangeAbAttr", {
|
||||
pokemon: this.pokemon,
|
||||
move,
|
||||
opponent: targets[0],
|
||||
});
|
||||
}
|
||||
|
||||
if (showText) {
|
||||
this.showMoveText();
|
||||
}
|
||||
|
||||
this.pokemon.pushMoveHistory({
|
||||
move: this.move.moveId,
|
||||
targets: this.targets,
|
||||
result: MoveResult.FAIL,
|
||||
useMode: this.useMode,
|
||||
});
|
||||
|
||||
// Use move-specific failure messages if present before checking terrain/weather blockage
|
||||
// and falling back to the classic "But it failed!".
|
||||
const failureMessage =
|
||||
move.getFailedText(this.pokemon, targets[0], move) ||
|
||||
(failedDueToTerrain
|
||||
? getTerrainBlockMessage(targets[0], globalScene.arena.getTerrainType())
|
||||
: failedDueToWeather
|
||||
? getWeatherBlockMessage(globalScene.arena.getWeatherType())
|
||||
: i18next.t("battle:attackFailed"));
|
||||
|
||||
this.showFailedText(failureMessage);
|
||||
|
||||
// Remove the user from its semi-invulnerable state (if applicable)
|
||||
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a {@linkcode MoveChargePhase} for this phase's invoked move.
|
||||
* Does NOT consume PP (occurs on the 2nd strike of the move)
|
||||
*/
|
||||
protected chargeMove() {
|
||||
const move = this.move.getMove();
|
||||
const targets = this.getActiveTargetPokemon();
|
||||
|
||||
this.showMoveText();
|
||||
|
||||
// Conditions currently assume single target
|
||||
// TODO: Is this sustainable?
|
||||
if (!move.applyConditions(this.pokemon, targets[0], move)) {
|
||||
this.pokemon.pushMoveHistory({
|
||||
move: this.move.moveId,
|
||||
targets: this.targets,
|
||||
result: MoveResult.FAIL,
|
||||
useMode: this.useMode,
|
||||
});
|
||||
|
||||
const failureMessage = move.getFailedText(this.pokemon, targets[0], move);
|
||||
this.showMoveText();
|
||||
this.showFailedText(failureMessage ?? undefined);
|
||||
|
||||
// Remove the user from its semi-invulnerable state (if applicable)
|
||||
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
|
||||
this.failMove(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Protean and Libero apply on the charging turn of charge moves
|
||||
// Protean and Libero apply on the charging turn of charge moves, even before showing usage text
|
||||
applyAbAttrs("PokemonTypeChangeAbAttr", {
|
||||
pokemon: this.pokemon,
|
||||
move: this.move.getMove(),
|
||||
move,
|
||||
opponent: targets[0],
|
||||
});
|
||||
|
||||
this.showMoveText();
|
||||
globalScene.phaseManager.unshiftNew(
|
||||
"MoveChargePhase",
|
||||
this.pokemon.getBattlerIndex(),
|
||||
@ -489,7 +481,7 @@ export class MovePhase extends BattlePhase {
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a {@linkcode MoveEndPhase} and then ends the phase
|
||||
* Queue a {@linkcode MoveEndPhase} and then end this phase.
|
||||
*/
|
||||
public end(): void {
|
||||
globalScene.phaseManager.unshiftNew(
|
||||
@ -503,15 +495,15 @@ export class MovePhase extends BattlePhase {
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies PP increasing abilities (currently only {@link AbilityId.PRESSURE Pressure}) if they exist on the target pokemon.
|
||||
* Applies PP increasing abilities (currently only {@linkcode AbilityId.PRESSURE | Pressure}) if they exist on the target pokemon.
|
||||
* Note that targets must include only active pokemon.
|
||||
*
|
||||
* TODO: This hardcodes the PP increase at 1 per opponent, rather than deferring to the ability.
|
||||
*/
|
||||
public getPpIncreaseFromPressure(targets: Pokemon[]): number {
|
||||
const foesWithPressure = this.pokemon
|
||||
.getOpponents()
|
||||
.filter(o => targets.includes(o) && o.isActive(true) && o.hasAbilityWithAttr("IncreasePpAbAttr"));
|
||||
.getOpponents(true)
|
||||
.filter(o => targets.includes(o) && o.hasAbilityWithAttr("IncreasePpAbAttr"));
|
||||
return foesWithPressure.length;
|
||||
}
|
||||
|
||||
@ -521,105 +513,113 @@ export class MovePhase extends BattlePhase {
|
||||
* - Counterattacks, which pass a special value into the `targets` constructor param (`[`{@linkcode BattlerIndex.ATTACKER}`]`).
|
||||
*/
|
||||
protected resolveRedirectTarget(): void {
|
||||
if (this.targets.length === 1) {
|
||||
const currentTarget = this.targets[0];
|
||||
const redirectTarget = new NumberHolder(currentTarget);
|
||||
if (this.targets.length !== 1) {
|
||||
// Spread moves cannot be redirected
|
||||
return;
|
||||
}
|
||||
|
||||
// check move redirection abilities of every pokemon *except* the user.
|
||||
globalScene
|
||||
.getField(true)
|
||||
.filter(p => p !== this.pokemon)
|
||||
.forEach(p =>
|
||||
applyAbAttrs("RedirectMoveAbAttr", {
|
||||
pokemon: p,
|
||||
moveId: this.move.moveId,
|
||||
targetIndex: redirectTarget,
|
||||
sourcePokemon: this.pokemon,
|
||||
}),
|
||||
);
|
||||
const currentTarget = this.targets[0];
|
||||
const redirectTarget = new NumberHolder(currentTarget);
|
||||
|
||||
/** `true` if an Ability is responsible for redirecting the move to another target; `false` otherwise */
|
||||
let redirectedByAbility = currentTarget !== redirectTarget.value;
|
||||
// check move redirection abilities of every pokemon *except* the user.
|
||||
globalScene
|
||||
.getField(true)
|
||||
.filter(p => p !== this.pokemon)
|
||||
.forEach(pokemon => {
|
||||
applyAbAttrs("RedirectMoveAbAttr", {
|
||||
pokemon,
|
||||
moveId: this.move.moveId,
|
||||
targetIndex: redirectTarget,
|
||||
sourcePokemon: this.pokemon,
|
||||
});
|
||||
});
|
||||
|
||||
// check for center-of-attention tags (note that this will override redirect abilities)
|
||||
this.pokemon.getOpponents().forEach(p => {
|
||||
const redirectTag = p.getTag(CenterOfAttentionTag);
|
||||
/** `true` if an Ability is responsible for redirecting the move to another target; `false` otherwise */
|
||||
let redirectedByAbility = currentTarget !== redirectTarget.value;
|
||||
|
||||
// TODO: don't hardcode this interaction.
|
||||
// Handle interaction between the rage powder center-of-attention tag and moves used by grass types/overcoat-havers (which are immune to RP's redirect)
|
||||
if (
|
||||
redirectTag &&
|
||||
(!redirectTag.powder ||
|
||||
(!this.pokemon.isOfType(PokemonType.GRASS) && !this.pokemon.hasAbility(AbilityId.OVERCOAT)))
|
||||
) {
|
||||
redirectTarget.value = p.getBattlerIndex();
|
||||
redirectedByAbility = false;
|
||||
// check for center-of-attention tags (note that this will override redirect abilities)
|
||||
this.pokemon.getOpponents(true).forEach(p => {
|
||||
const redirectTag = p.getTag(CenterOfAttentionTag);
|
||||
|
||||
// TODO: don't hardcode this interaction.
|
||||
// Handle interaction between the rage powder center-of-attention tag and moves used by grass types/overcoat-havers (which are immune to RP's redirect)
|
||||
if (
|
||||
redirectTag &&
|
||||
(!redirectTag.powder ||
|
||||
(!this.pokemon.isOfType(PokemonType.GRASS) && !this.pokemon.hasAbility(AbilityId.OVERCOAT)))
|
||||
) {
|
||||
redirectTarget.value = p.getBattlerIndex();
|
||||
redirectedByAbility = false;
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Don't hardcode these ability interactions
|
||||
if (currentTarget !== redirectTarget.value) {
|
||||
const bypassRedirectAttrs = this.move.getMove().getAttrs("BypassRedirectAttr");
|
||||
bypassRedirectAttrs.forEach(attr => {
|
||||
if (!attr.abilitiesOnly || redirectedByAbility) {
|
||||
redirectTarget.value = currentTarget;
|
||||
}
|
||||
});
|
||||
|
||||
if (currentTarget !== redirectTarget.value) {
|
||||
const bypassRedirectAttrs = this.move.getMove().getAttrs("BypassRedirectAttr");
|
||||
bypassRedirectAttrs.forEach(attr => {
|
||||
if (!attr.abilitiesOnly || redirectedByAbility) {
|
||||
redirectTarget.value = currentTarget;
|
||||
}
|
||||
});
|
||||
|
||||
if (this.pokemon.hasAbilityWithAttr("BlockRedirectAbAttr")) {
|
||||
redirectTarget.value = currentTarget;
|
||||
// TODO: Ability displays should be handled by the ability
|
||||
globalScene.phaseManager.queueAbilityDisplay(
|
||||
this.pokemon,
|
||||
this.pokemon.getPassiveAbility().hasAttr("BlockRedirectAbAttr"),
|
||||
true,
|
||||
);
|
||||
globalScene.phaseManager.queueAbilityDisplay(
|
||||
this.pokemon,
|
||||
this.pokemon.getPassiveAbility().hasAttr("BlockRedirectAbAttr"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
this.targets[0] = redirectTarget.value;
|
||||
if (this.pokemon.hasAbilityWithAttr("BlockRedirectAbAttr")) {
|
||||
redirectTarget.value = currentTarget;
|
||||
// TODO: Ability displays should be handled by the ability
|
||||
globalScene.phaseManager.queueAbilityDisplay(
|
||||
this.pokemon,
|
||||
this.pokemon.getPassiveAbility().hasAttr("BlockRedirectAbAttr"),
|
||||
true,
|
||||
);
|
||||
globalScene.phaseManager.queueAbilityDisplay(
|
||||
this.pokemon,
|
||||
this.pokemon.getPassiveAbility().hasAttr("BlockRedirectAbAttr"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
this.targets[0] = redirectTarget.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counter-attacking moves pass in `[`{@linkcode BattlerIndex.ATTACKER}`]` into the constructor's `targets` param.
|
||||
* This function modifies `this.targets` to reflect the actual battler index of the user's last
|
||||
* attacker.
|
||||
* Update the targets of any counter-attacking moves with `[`{@linkcode BattlerIndex.ATTACKER}`]` set
|
||||
* to reflect the actual battler index of the user's last attacker.
|
||||
*
|
||||
* If there is no last attacker, or they are no longer on the field, a message is displayed and the
|
||||
* If there is no last attacker or they are no longer on the field, a message is displayed and the
|
||||
* move is marked for failure.
|
||||
* @todo Make this a feature of the move rather than basing logic on {@linkcode BattlerIndex.ATTACKER}
|
||||
*/
|
||||
protected resolveCounterAttackTarget(): void {
|
||||
if (this.targets.length === 1 && this.targets[0] === BattlerIndex.ATTACKER) {
|
||||
if (this.pokemon.turnData.attacksReceived.length) {
|
||||
this.targets[0] = this.pokemon.turnData.attacksReceived[0].sourceBattlerIndex;
|
||||
if (this.targets.length !== 1 || this.targets[0] !== BattlerIndex.ATTACKER) {
|
||||
return;
|
||||
}
|
||||
|
||||
// account for metal burst and comeuppance hitting remaining targets in double battles
|
||||
// counterattack will redirect to remaining ally if original attacker faints
|
||||
if (globalScene.currentBattle.double && this.move.getMove().hasFlag(MoveFlags.REDIRECT_COUNTER)) {
|
||||
if (globalScene.getField()[this.targets[0]].hp === 0) {
|
||||
const opposingField = this.pokemon.isPlayer() ? globalScene.getEnemyField() : globalScene.getPlayerField();
|
||||
this.targets[0] = opposingField.find(p => p.hp > 0)?.getBattlerIndex() ?? BattlerIndex.ATTACKER;
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: This should be covered in move conditions
|
||||
if (this.pokemon.turnData.attacksReceived.length === 0) {
|
||||
this.fail();
|
||||
this.showMoveText();
|
||||
this.showFailedText();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.targets[0] === BattlerIndex.ATTACKER) {
|
||||
this.fail();
|
||||
this.showMoveText();
|
||||
this.showFailedText();
|
||||
}
|
||||
this.targets[0] = this.pokemon.turnData.attacksReceived[0].sourceBattlerIndex;
|
||||
|
||||
// account for metal burst and comeuppance hitting remaining targets in double battles
|
||||
// counterattack will redirect to remaining ally if original attacker faints
|
||||
if (
|
||||
globalScene.currentBattle.double &&
|
||||
this.move.getMove().hasFlag(MoveFlags.REDIRECT_COUNTER) &&
|
||||
globalScene.getField()[this.targets[0]].hp === 0
|
||||
) {
|
||||
const opposingField = this.pokemon.isPlayer() ? globalScene.getEnemyField() : globalScene.getPlayerField();
|
||||
this.targets[0] = opposingField.find(p => p.hp > 0)?.getBattlerIndex() ?? BattlerIndex.ATTACKER;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the case where the move was cancelled or failed:
|
||||
* - Uses PP if the move failed (not cancelled) and should use PP (failed moves are not affected by {@link AbilityId.PRESSURE Pressure})
|
||||
* - Records a cancelled OR failed move in move history, so abilities like {@link AbilityId.TRUANT Truant} don't trigger on the
|
||||
* - Uses PP if the move failed (not cancelled) and should use PP (failed moves are not affected by {@linkcode AbilityId.PRESSURE | Pressure})
|
||||
* - Records a cancelled OR failed move in move history, so abilities like {@linkcode AbilityId.TRUANT | Truant} don't trigger on the
|
||||
* next turn and soft-lock.
|
||||
* - Lapses `MOVE_EFFECT` tags:
|
||||
* - Semi-invulnerable battler tags (Fly/Dive/etc.) are intended to lapse on move effects, but also need
|
||||
@ -627,52 +627,55 @@ export class MovePhase extends BattlePhase {
|
||||
*
|
||||
* TODO: ...this seems weird.
|
||||
* - Lapses `AFTER_MOVE` tags:
|
||||
* - This handles the effects of {@link MoveId.SUBSTITUTE Substitute}
|
||||
* - This handles the effects of {@linkcode MoveId.SUBSTITUTE | Substitute}
|
||||
* - Removes the second turn of charge moves
|
||||
*/
|
||||
protected handlePreMoveFailures(): void {
|
||||
if (this.cancelled || this.failed) {
|
||||
if (this.failed) {
|
||||
const ppUsed = isIgnorePP(this.useMode) ? 0 : 1;
|
||||
|
||||
if (ppUsed) {
|
||||
this.move.usePp();
|
||||
}
|
||||
|
||||
globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed));
|
||||
}
|
||||
|
||||
if (this.cancelled && this.pokemon.summonData.tags?.find(t => t.tagType === BattlerTagType.FRENZY)) {
|
||||
frenzyMissFunc(this.pokemon, this.move.getMove());
|
||||
}
|
||||
|
||||
this.pokemon.pushMoveHistory({
|
||||
move: MoveId.NONE,
|
||||
result: MoveResult.FAIL,
|
||||
targets: this.targets,
|
||||
useMode: this.useMode,
|
||||
});
|
||||
|
||||
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
|
||||
this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE);
|
||||
|
||||
this.pokemon.getMoveQueue().shift();
|
||||
if (!this.cancelled && !this.failed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.failed) {
|
||||
// TODO: should this consider struggle?
|
||||
const ppUsed = isIgnorePP(this.useMode) ? 0 : 1;
|
||||
this.move.usePp(ppUsed);
|
||||
globalScene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed));
|
||||
}
|
||||
|
||||
if (this.cancelled && this.pokemon.summonData.tags.some(t => t.tagType === BattlerTagType.FRENZY)) {
|
||||
frenzyMissFunc(this.pokemon, this.move.getMove());
|
||||
}
|
||||
|
||||
this.pokemon.pushMoveHistory({
|
||||
move: MoveId.NONE,
|
||||
result: MoveResult.FAIL,
|
||||
targets: this.targets,
|
||||
useMode: this.useMode,
|
||||
});
|
||||
|
||||
this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT);
|
||||
this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE);
|
||||
|
||||
// This clears out 2 turn moves after they've been used
|
||||
// TODO: Remove post move queue refactor
|
||||
this.pokemon.getMoveQueue().shift();
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the move's usage text to the player, unless it's a charge turn (ie: {@link MoveId.SOLAR_BEAM Solar Beam}),
|
||||
* the pokemon is on a recharge turn (ie: {@link MoveId.HYPER_BEAM Hyper Beam}), or a 2-turn move was interrupted (ie: {@link MoveId.FLY Fly}).
|
||||
* Displays the move's usage text to the player as applicable for the move being used.
|
||||
*/
|
||||
public showMoveText(): void {
|
||||
if (this.move.moveId === MoveId.NONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pokemon.getTag(BattlerTagType.RECHARGING) || this.pokemon.getTag(BattlerTagType.INTERRUPTED)) {
|
||||
// No text for Moves.NONE, recharging/2-turn moves or interrupted moves
|
||||
if (
|
||||
this.move.moveId === MoveId.NONE ||
|
||||
this.pokemon.getTag(BattlerTagType.RECHARGING) ||
|
||||
this.pokemon.getTag(BattlerTagType.INTERRUPTED)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Play message for magic coat reflection
|
||||
// TODO: This should be done by the move...
|
||||
globalScene.phaseManager.queueMessage(
|
||||
i18next.t(isReflected(this.useMode) ? "battle:magicCoatActivated" : "battle:useMove", {
|
||||
pokemonNameWithAffix: getPokemonNameWithAffix(this.pokemon),
|
||||
@ -686,7 +689,12 @@ export class MovePhase extends BattlePhase {
|
||||
applyMoveAttrs("PreMoveMessageAttr", this.pokemon, this.pokemon.getOpponents(false)[0], this.move.getMove());
|
||||
}
|
||||
|
||||
public showFailedText(failedText: string = i18next.t("battle:attackFailed")): void {
|
||||
/**
|
||||
* Display the text for a move failing to execute.
|
||||
* @param failedText - The failure text to display; defaults to `"battle:attackFailed"` locale key
|
||||
* ("But it failed!" in english)
|
||||
*/
|
||||
public showFailedText(failedText = i18next.t("battle:attackFailed")): void {
|
||||
globalScene.phaseManager.queueMessage(failedText);
|
||||
}
|
||||
}
|
||||
|
@ -42,62 +42,67 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
|
||||
super(UiMode.FIGHT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the visibility of the objects in the move info container.
|
||||
*/
|
||||
private setInfoVis(visibility: boolean): void {
|
||||
this.moveInfoContainer.iterate((o: Phaser.GameObjects.Components.Visible) => o.setVisible(visibility));
|
||||
}
|
||||
|
||||
setup() {
|
||||
const ui = this.getUi();
|
||||
|
||||
this.movesContainer = globalScene.add.container(18, -38.7);
|
||||
this.movesContainer.setName(FightUiHandler.MOVES_CONTAINER_NAME);
|
||||
this.movesContainer = globalScene.add.container(18, -38.7).setName(FightUiHandler.MOVES_CONTAINER_NAME);
|
||||
ui.add(this.movesContainer);
|
||||
|
||||
this.moveInfoContainer = globalScene.add.container(1, 0);
|
||||
this.moveInfoContainer.setName("move-info");
|
||||
this.moveInfoContainer = globalScene.add.container(1, 0).setName("move-info");
|
||||
ui.add(this.moveInfoContainer);
|
||||
|
||||
this.typeIcon = globalScene.add.sprite(
|
||||
globalScene.scaledCanvas.width - 57,
|
||||
-36,
|
||||
getLocalizedSpriteKey("types"),
|
||||
"unknown",
|
||||
);
|
||||
this.typeIcon.setVisible(false);
|
||||
this.moveInfoContainer.add(this.typeIcon);
|
||||
this.typeIcon = globalScene.add
|
||||
.sprite(globalScene.scaledCanvas.width - 57, -36, getLocalizedSpriteKey("types"), "unknown")
|
||||
.setVisible(false);
|
||||
|
||||
this.moveCategoryIcon = globalScene.add.sprite(globalScene.scaledCanvas.width - 25, -36, "categories", "physical");
|
||||
this.moveCategoryIcon.setVisible(false);
|
||||
this.moveInfoContainer.add(this.moveCategoryIcon);
|
||||
this.moveCategoryIcon = globalScene.add
|
||||
.sprite(globalScene.scaledCanvas.width - 25, -36, "categories", "physical")
|
||||
.setVisible(false);
|
||||
|
||||
this.ppLabel = addTextObject(globalScene.scaledCanvas.width - 70, -26, "PP", TextStyle.MOVE_INFO_CONTENT);
|
||||
this.ppLabel.setOrigin(0.0, 0.5);
|
||||
this.ppLabel.setVisible(false);
|
||||
this.ppLabel.setText(i18next.t("fightUiHandler:pp"));
|
||||
this.moveInfoContainer.add(this.ppLabel);
|
||||
this.ppLabel = addTextObject(globalScene.scaledCanvas.width - 70, -26, "PP", TextStyle.MOVE_INFO_CONTENT)
|
||||
.setOrigin(0.0, 0.5)
|
||||
.setVisible(false)
|
||||
.setText(i18next.t("fightUiHandler:pp"));
|
||||
|
||||
this.ppText = addTextObject(globalScene.scaledCanvas.width - 12, -26, "--/--", TextStyle.MOVE_INFO_CONTENT);
|
||||
this.ppText.setOrigin(1, 0.5);
|
||||
this.ppText.setVisible(false);
|
||||
this.moveInfoContainer.add(this.ppText);
|
||||
this.ppText = addTextObject(globalScene.scaledCanvas.width - 12, -26, "--/--", TextStyle.MOVE_INFO_CONTENT)
|
||||
.setOrigin(1, 0.5)
|
||||
.setVisible(false);
|
||||
|
||||
this.powerLabel = addTextObject(globalScene.scaledCanvas.width - 70, -18, "POWER", TextStyle.MOVE_INFO_CONTENT);
|
||||
this.powerLabel.setOrigin(0.0, 0.5);
|
||||
this.powerLabel.setVisible(false);
|
||||
this.powerLabel.setText(i18next.t("fightUiHandler:power"));
|
||||
this.moveInfoContainer.add(this.powerLabel);
|
||||
this.powerLabel = addTextObject(globalScene.scaledCanvas.width - 70, -18, "POWER", TextStyle.MOVE_INFO_CONTENT)
|
||||
.setOrigin(0.0, 0.5)
|
||||
.setVisible(false)
|
||||
.setText(i18next.t("fightUiHandler:power"));
|
||||
|
||||
this.powerText = addTextObject(globalScene.scaledCanvas.width - 12, -18, "---", TextStyle.MOVE_INFO_CONTENT);
|
||||
this.powerText.setOrigin(1, 0.5);
|
||||
this.powerText.setVisible(false);
|
||||
this.moveInfoContainer.add(this.powerText);
|
||||
this.powerText = addTextObject(globalScene.scaledCanvas.width - 12, -18, "---", TextStyle.MOVE_INFO_CONTENT)
|
||||
.setOrigin(1, 0.5)
|
||||
.setVisible(false);
|
||||
|
||||
this.accuracyLabel = addTextObject(globalScene.scaledCanvas.width - 70, -10, "ACC", TextStyle.MOVE_INFO_CONTENT);
|
||||
this.accuracyLabel.setOrigin(0.0, 0.5);
|
||||
this.accuracyLabel.setVisible(false);
|
||||
this.accuracyLabel.setText(i18next.t("fightUiHandler:accuracy"));
|
||||
this.moveInfoContainer.add(this.accuracyLabel);
|
||||
this.accuracyLabel = addTextObject(globalScene.scaledCanvas.width - 70, -10, "ACC", TextStyle.MOVE_INFO_CONTENT)
|
||||
.setOrigin(0.0, 0.5)
|
||||
.setVisible(false)
|
||||
.setText(i18next.t("fightUiHandler:accuracy"));
|
||||
|
||||
this.accuracyText = addTextObject(globalScene.scaledCanvas.width - 12, -10, "---", TextStyle.MOVE_INFO_CONTENT);
|
||||
this.accuracyText.setOrigin(1, 0.5);
|
||||
this.accuracyText.setVisible(false);
|
||||
this.moveInfoContainer.add(this.accuracyText);
|
||||
this.accuracyText = addTextObject(globalScene.scaledCanvas.width - 12, -10, "---", TextStyle.MOVE_INFO_CONTENT)
|
||||
.setOrigin(1, 0.5)
|
||||
.setVisible(false);
|
||||
|
||||
this.moveInfoContainer.add([
|
||||
this.typeIcon,
|
||||
this.moveCategoryIcon,
|
||||
this.ppLabel,
|
||||
this.ppText,
|
||||
this.powerLabel,
|
||||
this.powerText,
|
||||
this.accuracyLabel,
|
||||
this.accuracyText,
|
||||
]);
|
||||
|
||||
// prepare move overlay
|
||||
const overlayScale = 1;
|
||||
@ -114,15 +119,14 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
|
||||
});
|
||||
ui.add(this.moveInfoOverlay);
|
||||
// register the overlay to receive toggle events
|
||||
globalScene.addInfoToggle(this.moveInfoOverlay);
|
||||
globalScene.addInfoToggle(this);
|
||||
globalScene.addInfoToggle(this.moveInfoOverlay, this);
|
||||
}
|
||||
|
||||
show(args: any[]): boolean {
|
||||
override show(args: [number?, Command?]): boolean {
|
||||
super.show(args);
|
||||
|
||||
this.fieldIndex = args.length ? (args[0] as number) : 0;
|
||||
this.fromCommand = args.length > 1 ? (args[1] as Command) : Command.FIGHT;
|
||||
this.fieldIndex = args[0] ?? 0;
|
||||
this.fromCommand = args[1] ?? Command.FIGHT;
|
||||
|
||||
const messageHandler = this.getUi().getMessageHandler();
|
||||
messageHandler.bg.setVisible(false);
|
||||
@ -131,8 +135,6 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
|
||||
const pokemon = (globalScene.phaseManager.getCurrentPhase() as CommandPhase).getPokemon();
|
||||
if (pokemon.tempSummonData.turnCount <= 1) {
|
||||
this.setCursor(0);
|
||||
} else {
|
||||
this.setCursor(this.getCursor());
|
||||
}
|
||||
this.displayMoves();
|
||||
this.toggleInfo(false); // in case cancel was pressed while info toggle is active
|
||||
@ -147,21 +149,10 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
|
||||
*/
|
||||
processInput(button: Button): boolean {
|
||||
const ui = this.getUi();
|
||||
const cursor = this.getCursor();
|
||||
let success = false;
|
||||
const cursor = this.getCursor();
|
||||
|
||||
switch (button) {
|
||||
case Button.CANCEL:
|
||||
{
|
||||
// Attempts to back out of the move selection pane are blocked in certain MEs
|
||||
// TODO: Should we allow showing the summary menu at least?
|
||||
const { battleType, mysteryEncounter } = globalScene.currentBattle;
|
||||
if (battleType !== BattleType.MYSTERY_ENCOUNTER || !mysteryEncounter?.skipToFightInput) {
|
||||
ui.setMode(UiMode.COMMAND, this.fieldIndex);
|
||||
success = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case Button.ACTION:
|
||||
if (
|
||||
(globalScene.phaseManager.getCurrentPhase() as CommandPhase).handleCommand(
|
||||
@ -175,6 +166,15 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
|
||||
ui.playError();
|
||||
}
|
||||
break;
|
||||
case Button.CANCEL: {
|
||||
// Cannot back out of fight menu if skipToFightInput is enabled
|
||||
const { battleType, mysteryEncounter } = globalScene.currentBattle;
|
||||
if (battleType !== BattleType.MYSTERY_ENCOUNTER || !mysteryEncounter?.skipToFightInput) {
|
||||
ui.setMode(UiMode.COMMAND, this.fieldIndex);
|
||||
success = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Button.UP:
|
||||
if (cursor >= 2) {
|
||||
success = this.setCursor(cursor - 2);
|
||||
@ -195,8 +195,6 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
|
||||
success = this.setCursor(cursor + 1);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// other inputs do nothing while in fight menu
|
||||
}
|
||||
|
||||
if (success) {
|
||||
@ -206,21 +204,26 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust the visibility of move names and the cursor icon when the info overlay is toggled
|
||||
* @param visible - The visibility of the info overlay; the move names and cursor's visibility will be set to the opposite
|
||||
*/
|
||||
toggleInfo(visible: boolean): void {
|
||||
// The info overlay will already fade in, so we should hide the move name text and cursor immediately
|
||||
// rather than adjusting alpha via a tween.
|
||||
if (visible) {
|
||||
this.movesContainer.setVisible(false);
|
||||
this.cursorObj?.setVisible(false);
|
||||
this.movesContainer.setVisible(false).setAlpha(0);
|
||||
this.cursorObj?.setVisible(false).setAlpha(0);
|
||||
return;
|
||||
}
|
||||
globalScene.tweens.add({
|
||||
targets: [this.movesContainer, this.cursorObj],
|
||||
duration: fixedInt(125),
|
||||
ease: "Sine.easeInOut",
|
||||
alpha: visible ? 0 : 1,
|
||||
alpha: 1,
|
||||
});
|
||||
if (!visible) {
|
||||
this.movesContainer.setVisible(true);
|
||||
this.cursorObj?.setVisible(true);
|
||||
}
|
||||
this.movesContainer.setVisible(true);
|
||||
this.cursorObj?.setVisible(true);
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
@ -231,6 +234,64 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
|
||||
return !this.fieldIndex ? this.cursor : this.cursor2;
|
||||
}
|
||||
|
||||
/** @returns TextStyle according to percentage of PP remaining */
|
||||
private static ppRatioToColor(ppRatio: number): TextStyle {
|
||||
if (ppRatio > 0.25 && ppRatio <= 0.5) {
|
||||
return TextStyle.MOVE_PP_HALF_FULL;
|
||||
}
|
||||
if (ppRatio > 0 && ppRatio <= 0.25) {
|
||||
return TextStyle.MOVE_PP_NEAR_EMPTY;
|
||||
}
|
||||
if (ppRatio === 0) {
|
||||
return TextStyle.MOVE_PP_EMPTY;
|
||||
}
|
||||
return TextStyle.MOVE_PP_FULL; // default to full if ppRatio is invalid
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the move info overlay with the information of the move at the given cursor index
|
||||
* @param cursor - The cursor position to set the move info for
|
||||
*/
|
||||
private setMoveInfo(cursor: number): void {
|
||||
const pokemon = (globalScene.phaseManager.getCurrentPhase() as CommandPhase).getPokemon();
|
||||
const moveset = pokemon.getMoveset();
|
||||
|
||||
const hasMove = cursor < moveset.length;
|
||||
this.setInfoVis(hasMove);
|
||||
|
||||
if (!hasMove) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pokemonMove = moveset[cursor];
|
||||
const moveType = pokemon.getMoveType(pokemonMove.getMove());
|
||||
const textureKey = getLocalizedSpriteKey("types");
|
||||
this.typeIcon.setTexture(textureKey, PokemonType[moveType].toLowerCase()).setScale(0.8);
|
||||
|
||||
const moveCategory = pokemonMove.getMove().category;
|
||||
this.moveCategoryIcon.setTexture("categories", MoveCategory[moveCategory].toLowerCase()).setScale(1.0);
|
||||
const power = pokemonMove.getMove().power;
|
||||
const accuracy = pokemonMove.getMove().accuracy;
|
||||
const maxPP = pokemonMove.getMovePp();
|
||||
const pp = maxPP - pokemonMove.ppUsed;
|
||||
|
||||
const ppLeftStr = padInt(pp, 2, " ");
|
||||
const ppMaxStr = padInt(maxPP, 2, " ");
|
||||
this.ppText.setText(`${ppLeftStr}/${ppMaxStr}`);
|
||||
this.powerText.setText(`${power >= 0 ? power : "---"}`);
|
||||
this.accuracyText.setText(`${accuracy >= 0 ? accuracy : "---"}`);
|
||||
|
||||
const ppColorStyle = FightUiHandler.ppRatioToColor(pp / maxPP);
|
||||
|
||||
//** Changes the text color and shadow according to the determined TextStyle */
|
||||
this.ppText.setColor(this.getTextColor(ppColorStyle, false)).setShadowColor(this.getTextColor(ppColorStyle, true));
|
||||
this.moveInfoOverlay.show(pokemonMove.getMove());
|
||||
|
||||
pokemon.getOpponents().forEach(opponent => {
|
||||
(opponent as EnemyPokemon).updateEffectiveness(this.getEffectivenessText(pokemon, opponent, pokemonMove));
|
||||
});
|
||||
}
|
||||
|
||||
setCursor(cursor: number): boolean {
|
||||
const ui = this.getUi();
|
||||
|
||||
@ -244,6 +305,8 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
|
||||
}
|
||||
}
|
||||
|
||||
this.setMoveInfo(cursor);
|
||||
|
||||
if (!this.cursorObj) {
|
||||
const isTera = this.fromCommand === Command.TERA;
|
||||
this.cursorObj = globalScene.add.image(0, 0, isTera ? "cursor_tera" : "cursor");
|
||||
@ -251,61 +314,6 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
|
||||
ui.add(this.cursorObj);
|
||||
}
|
||||
|
||||
const pokemon = (globalScene.phaseManager.getCurrentPhase() as CommandPhase).getPokemon();
|
||||
const moveset = pokemon.getMoveset();
|
||||
|
||||
const hasMove = cursor < moveset.length;
|
||||
|
||||
if (hasMove) {
|
||||
const pokemonMove = moveset[cursor];
|
||||
const moveType = pokemon.getMoveType(pokemonMove.getMove());
|
||||
const textureKey = getLocalizedSpriteKey("types");
|
||||
this.typeIcon.setTexture(textureKey, PokemonType[moveType].toLowerCase()).setScale(0.8);
|
||||
|
||||
const moveCategory = pokemonMove.getMove().category;
|
||||
this.moveCategoryIcon.setTexture("categories", MoveCategory[moveCategory].toLowerCase()).setScale(1.0);
|
||||
const power = pokemonMove.getMove().power;
|
||||
const accuracy = pokemonMove.getMove().accuracy;
|
||||
const maxPP = pokemonMove.getMovePp();
|
||||
const pp = maxPP - pokemonMove.ppUsed;
|
||||
|
||||
const ppLeftStr = padInt(pp, 2, " ");
|
||||
const ppMaxStr = padInt(maxPP, 2, " ");
|
||||
this.ppText.setText(`${ppLeftStr}/${ppMaxStr}`);
|
||||
this.powerText.setText(`${power >= 0 ? power : "---"}`);
|
||||
this.accuracyText.setText(`${accuracy >= 0 ? accuracy : "---"}`);
|
||||
|
||||
const ppPercentLeft = pp / maxPP;
|
||||
|
||||
//** Determines TextStyle according to percentage of PP remaining */
|
||||
let ppColorStyle = TextStyle.MOVE_PP_FULL;
|
||||
if (ppPercentLeft > 0.25 && ppPercentLeft <= 0.5) {
|
||||
ppColorStyle = TextStyle.MOVE_PP_HALF_FULL;
|
||||
} else if (ppPercentLeft > 0 && ppPercentLeft <= 0.25) {
|
||||
ppColorStyle = TextStyle.MOVE_PP_NEAR_EMPTY;
|
||||
} else if (ppPercentLeft === 0) {
|
||||
ppColorStyle = TextStyle.MOVE_PP_EMPTY;
|
||||
}
|
||||
|
||||
//** Changes the text color and shadow according to the determined TextStyle */
|
||||
this.ppText.setColor(this.getTextColor(ppColorStyle, false));
|
||||
this.ppText.setShadowColor(this.getTextColor(ppColorStyle, true));
|
||||
this.moveInfoOverlay.show(pokemonMove.getMove());
|
||||
|
||||
pokemon.getOpponents().forEach(opponent => {
|
||||
(opponent as EnemyPokemon).updateEffectiveness(this.getEffectivenessText(pokemon, opponent, pokemonMove));
|
||||
});
|
||||
}
|
||||
|
||||
this.typeIcon.setVisible(hasMove);
|
||||
this.ppLabel.setVisible(hasMove);
|
||||
this.ppText.setVisible(hasMove);
|
||||
this.powerLabel.setVisible(hasMove);
|
||||
this.powerText.setVisible(hasMove);
|
||||
this.accuracyLabel.setVisible(hasMove);
|
||||
this.accuracyText.setVisible(hasMove);
|
||||
this.moveCategoryIcon.setVisible(hasMove);
|
||||
|
||||
this.cursorObj.setPosition(13 + (cursor % 2 === 1 ? 114 : 0), -31 + (cursor >= 2 ? 15 : 0));
|
||||
|
||||
return changed;
|
||||
@ -336,14 +344,19 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
|
||||
const moveset = pokemon.getMoveset();
|
||||
|
||||
for (let moveIndex = 0; moveIndex < 4; moveIndex++) {
|
||||
const moveText = addTextObject(moveIndex % 2 === 0 ? 0 : 114, moveIndex < 2 ? 0 : 16, "-", TextStyle.WINDOW);
|
||||
moveText.setName("text-empty-move");
|
||||
const moveText = addTextObject(
|
||||
moveIndex % 2 === 0 ? 0 : 114,
|
||||
moveIndex < 2 ? 0 : 16,
|
||||
"-",
|
||||
TextStyle.WINDOW,
|
||||
).setName("text-empty-move");
|
||||
|
||||
if (moveIndex < moveset.length) {
|
||||
const pokemonMove = moveset[moveIndex]!; // TODO is the bang correct?
|
||||
moveText.setText(pokemonMove.getName());
|
||||
moveText.setName(pokemonMove.getName());
|
||||
moveText.setColor(this.getMoveColor(pokemon, pokemonMove) ?? moveText.style.color);
|
||||
moveText
|
||||
.setText(pokemonMove.getName())
|
||||
.setName(pokemonMove.getName())
|
||||
.setColor(this.getMoveColor(pokemon, pokemonMove) ?? moveText.style.color);
|
||||
}
|
||||
|
||||
this.movesContainer.add(moveText);
|
||||
@ -386,14 +399,7 @@ export default class FightUiHandler extends UiHandler implements InfoToggle {
|
||||
super.clear();
|
||||
const messageHandler = this.getUi().getMessageHandler();
|
||||
this.clearMoves();
|
||||
this.typeIcon.setVisible(false);
|
||||
this.ppLabel.setVisible(false);
|
||||
this.ppText.setVisible(false);
|
||||
this.powerLabel.setVisible(false);
|
||||
this.powerText.setVisible(false);
|
||||
this.accuracyLabel.setVisible(false);
|
||||
this.accuracyText.setVisible(false);
|
||||
this.moveCategoryIcon.setVisible(false);
|
||||
this.setInfoVis(false);
|
||||
this.moveInfoOverlay.clear();
|
||||
messageHandler.bg.setVisible(true);
|
||||
this.eraseCursor();
|
||||
|
39
test/abilities/guard-dog.test.ts
Normal file
39
test/abilities/guard-dog.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import { Stat } from "#enums/stat";
|
||||
import GameManager from "#test/testUtils/gameManager";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
describe("Ability - Guard Dog", () => {
|
||||
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.GUARD_DOG)
|
||||
.battleStyle("single")
|
||||
.enemySpecies(SpeciesId.MAGIKARP)
|
||||
.enemyAbility(AbilityId.INTIMIDATE);
|
||||
});
|
||||
|
||||
it("should raise attack by 1 stage when Intimidated instead of being lowered", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.MABOSSTIFF]);
|
||||
|
||||
const mabostiff = game.field.getPlayerPokemon();
|
||||
expect(mabostiff.getStatStage(Stat.ATK)).toBe(1);
|
||||
expect(mabostiff.waveData.abilitiesApplied.has(AbilityId.GUARD_DOG)).toBe(true);
|
||||
expect(game.phaseInterceptor.log.filter(l => l === "StatStageChangePhase")).toHaveLength(1);
|
||||
});
|
||||
});
|
@ -1,11 +1,11 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import Phaser from "phaser";
|
||||
import GameManager from "#test/testUtils/gameManager";
|
||||
import { UiMode } from "#enums/ui-mode";
|
||||
import { Stat } from "#enums/stat";
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import { BattleType } from "#enums/battle-type";
|
||||
|
||||
describe("Abilities - Intimidate", () => {
|
||||
let phaserGame: Phaser.Game;
|
||||
@ -27,106 +27,64 @@ describe("Abilities - Intimidate", () => {
|
||||
.battleStyle("single")
|
||||
.enemySpecies(SpeciesId.RATTATA)
|
||||
.enemyAbility(AbilityId.INTIMIDATE)
|
||||
.enemyPassiveAbility(AbilityId.HYDRATION)
|
||||
.ability(AbilityId.INTIMIDATE)
|
||||
.startingWave(3)
|
||||
.passiveAbility(AbilityId.NO_GUARD)
|
||||
.enemyMoveset(MoveId.SPLASH);
|
||||
});
|
||||
|
||||
it("should lower ATK stat stage by 1 of enemy Pokemon on entry and player switch", async () => {
|
||||
await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
|
||||
game.onNextPrompt(
|
||||
"CheckSwitchPhase",
|
||||
UiMode.CONFIRM,
|
||||
() => {
|
||||
game.setMode(UiMode.MESSAGE);
|
||||
game.endPhase();
|
||||
},
|
||||
() => game.isCurrentPhase("CommandPhase") || game.isCurrentPhase("TurnInitPhase"),
|
||||
);
|
||||
await game.phaseInterceptor.to("CommandPhase", false);
|
||||
it("should lower all opponents' ATK by 1 stage on entry and switch", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
|
||||
|
||||
let playerPokemon = game.scene.getPlayerPokemon()!;
|
||||
const enemyPokemon = game.scene.getEnemyPokemon()!;
|
||||
|
||||
expect(playerPokemon.species.speciesId).toBe(SpeciesId.MIGHTYENA);
|
||||
expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1);
|
||||
expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-1);
|
||||
const enemy = game.field.getEnemyPokemon();
|
||||
expect(enemy.getStatStage(Stat.ATK)).toBe(-1);
|
||||
|
||||
game.doSwitchPokemon(1);
|
||||
await game.phaseInterceptor.run("CommandPhase");
|
||||
await game.phaseInterceptor.to("CommandPhase");
|
||||
|
||||
playerPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(playerPokemon.species.speciesId).toBe(SpeciesId.POOCHYENA);
|
||||
expect(playerPokemon.getStatStage(Stat.ATK)).toBe(0);
|
||||
expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-2);
|
||||
});
|
||||
|
||||
it("should lower ATK stat stage by 1 for every enemy Pokemon in a double battle on entry", async () => {
|
||||
game.override.battleStyle("double").startingWave(3);
|
||||
await game.classicMode.runToSummon([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
|
||||
game.onNextPrompt(
|
||||
"CheckSwitchPhase",
|
||||
UiMode.CONFIRM,
|
||||
() => {
|
||||
game.setMode(UiMode.MESSAGE);
|
||||
game.endPhase();
|
||||
},
|
||||
() => game.isCurrentPhase("CommandPhase") || game.isCurrentPhase("TurnInitPhase"),
|
||||
);
|
||||
await game.phaseInterceptor.to("CommandPhase", false);
|
||||
|
||||
const playerField = game.scene.getPlayerField()!;
|
||||
const enemyField = game.scene.getEnemyField()!;
|
||||
|
||||
expect(enemyField[0].getStatStage(Stat.ATK)).toBe(-2);
|
||||
expect(enemyField[1].getStatStage(Stat.ATK)).toBe(-2);
|
||||
expect(playerField[0].getStatStage(Stat.ATK)).toBe(-2);
|
||||
expect(playerField[1].getStatStage(Stat.ATK)).toBe(-2);
|
||||
});
|
||||
|
||||
it("should not activate again if there is no switch or new entry", async () => {
|
||||
game.override.startingWave(2).moveset([MoveId.SPLASH]);
|
||||
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
|
||||
|
||||
const playerPokemon = game.scene.getPlayerPokemon()!;
|
||||
const enemyPokemon = game.scene.getEnemyPokemon()!;
|
||||
|
||||
expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1);
|
||||
expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-1);
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.toNextTurn();
|
||||
|
||||
expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1);
|
||||
expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-1);
|
||||
expect(enemy.getStatStage(Stat.ATK)).toBe(-2);
|
||||
});
|
||||
|
||||
it("should lower ATK stat stage by 1 for every switch", async () => {
|
||||
game.override.moveset([MoveId.SPLASH]).enemyMoveset([MoveId.VOLT_SWITCH]).startingWave(5);
|
||||
await game.classicMode.startBattle([SpeciesId.MIGHTYENA, SpeciesId.POOCHYENA]);
|
||||
it("should lower ATK of all opponents in a double battle", async () => {
|
||||
game.override.battleStyle("double");
|
||||
await game.classicMode.startBattle([SpeciesId.MIGHTYENA]);
|
||||
|
||||
const playerPokemon = game.scene.getPlayerPokemon()!;
|
||||
let enemyPokemon = game.scene.getEnemyPokemon()!;
|
||||
const [enemy1, enemy2] = game.scene.getEnemyField();
|
||||
|
||||
expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1);
|
||||
expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-1);
|
||||
expect(enemy1.getStatStage(Stat.ATK)).toBe(-1);
|
||||
expect(enemy2.getStatStage(Stat.ATK)).toBe(-1);
|
||||
});
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
it("should not trigger on switching moves used by wild Pokemon", async () => {
|
||||
game.override.enemyMoveset(MoveId.VOLT_SWITCH).battleType(BattleType.WILD);
|
||||
await game.classicMode.startBattle([SpeciesId.MIGHTYENA]);
|
||||
|
||||
const player = game.field.getPlayerPokemon();
|
||||
expect(player.getStatStage(Stat.ATK)).toBe(-1);
|
||||
|
||||
game.move.use(MoveId.SPLASH);
|
||||
await game.toNextTurn();
|
||||
|
||||
enemyPokemon = game.scene.getEnemyPokemon()!;
|
||||
// doesn't lower attack due to not actually switching out
|
||||
expect(player.getStatStage(Stat.ATK)).toBe(-1);
|
||||
});
|
||||
|
||||
expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-2);
|
||||
expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(0);
|
||||
it("should trigger on moves that switch user/target out during trainer battles", async () => {
|
||||
game.override.battleType(BattleType.TRAINER).startingWave(50);
|
||||
await game.classicMode.startBattle([SpeciesId.MIGHTYENA]);
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
const player = game.field.getPlayerPokemon();
|
||||
expect(player.getStatStage(Stat.ATK)).toBe(-1);
|
||||
|
||||
game.move.use(MoveId.SPLASH);
|
||||
await game.move.forceEnemyMove(MoveId.TELEPORT);
|
||||
await game.toNextTurn();
|
||||
|
||||
enemyPokemon = game.scene.getEnemyPokemon()!;
|
||||
expect(player.getStatStage(Stat.ATK)).toBe(-2);
|
||||
|
||||
expect(playerPokemon.getStatStage(Stat.ATK)).toBe(-3);
|
||||
expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(0);
|
||||
game.move.use(MoveId.DRAGON_TAIL);
|
||||
await game.move.forceEnemyMove(MoveId.SPLASH);
|
||||
await game.toNextTurn();
|
||||
|
||||
expect(player.getStatStage(Stat.ATK)).toBe(-3);
|
||||
});
|
||||
});
|
||||
|
@ -1,297 +0,0 @@
|
||||
import { allMoves } from "#app/data/data-lists";
|
||||
import { PokemonType } from "#enums/pokemon-type";
|
||||
import { Weather } from "#app/data/weather";
|
||||
import type { PlayerPokemon } from "#app/field/pokemon";
|
||||
import { TurnEndPhase } from "#app/phases/turn-end-phase";
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { BattlerTagType } from "#enums/battler-tag-type";
|
||||
import { BiomeId } from "#enums/biome-id";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import { WeatherType } from "#enums/weather-type";
|
||||
import GameManager from "#test/testUtils/gameManager";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
describe("Abilities - Libero", () => {
|
||||
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
|
||||
.battleStyle("single")
|
||||
.ability(AbilityId.LIBERO)
|
||||
.startingLevel(100)
|
||||
.enemySpecies(SpeciesId.RATTATA)
|
||||
.enemyMoveset(MoveId.ENDURE);
|
||||
});
|
||||
|
||||
test("ability applies and changes a pokemon's type", async () => {
|
||||
game.override.moveset([MoveId.SPLASH]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.SPLASH);
|
||||
});
|
||||
|
||||
// Test for Gen9+ functionality, we are using previous funcionality
|
||||
test.skip("ability applies only once per switch in", async () => {
|
||||
game.override.moveset([MoveId.SPLASH, MoveId.AGILITY]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.BULBASAUR]);
|
||||
|
||||
let leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.SPLASH);
|
||||
|
||||
game.move.select(MoveId.AGILITY);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
expect(leadPokemon.waveData.abilitiesApplied).toContain(AbilityId.LIBERO);
|
||||
const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]];
|
||||
const moveType = PokemonType[allMoves[MoveId.AGILITY].type];
|
||||
expect(leadPokemonType).not.toBe(moveType);
|
||||
|
||||
await game.toNextTurn();
|
||||
game.doSwitchPokemon(1);
|
||||
await game.toNextTurn();
|
||||
game.doSwitchPokemon(1);
|
||||
await game.toNextTurn();
|
||||
|
||||
leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.SPLASH);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the pokemon's move has a variable type", async () => {
|
||||
game.override.moveset([MoveId.WEATHER_BALL]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.scene.arena.weather = new Weather(WeatherType.SUNNY);
|
||||
game.move.select(MoveId.WEATHER_BALL);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
expect(leadPokemon.waveData.abilitiesApplied).toContain(AbilityId.LIBERO);
|
||||
expect(leadPokemon.getTypes()).toHaveLength(1);
|
||||
const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]],
|
||||
moveType = PokemonType[PokemonType.FIRE];
|
||||
expect(leadPokemonType).toBe(moveType);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the type has changed by another ability", async () => {
|
||||
game.override.moveset([MoveId.TACKLE]).passiveAbility(AbilityId.REFRIGERATE);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.TACKLE);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
expect(leadPokemon.waveData.abilitiesApplied).toContain(AbilityId.LIBERO);
|
||||
expect(leadPokemon.getTypes()).toHaveLength(1);
|
||||
const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]],
|
||||
moveType = PokemonType[PokemonType.ICE];
|
||||
expect(leadPokemonType).toBe(moveType);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the pokemon's move calls another move", async () => {
|
||||
game.override.moveset([MoveId.NATURE_POWER]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.scene.arena.biomeType = BiomeId.MOUNTAIN;
|
||||
game.move.select(MoveId.NATURE_POWER);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.AIR_SLASH);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the pokemon's move is delayed / charging", async () => {
|
||||
game.override.moveset([MoveId.DIG]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.DIG);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.DIG);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the pokemon's move misses", async () => {
|
||||
game.override.moveset([MoveId.TACKLE]).enemyMoveset(MoveId.SPLASH);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.TACKLE);
|
||||
await game.move.forceMiss();
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
const enemyPokemon = game.scene.getEnemyPokemon()!;
|
||||
expect(enemyPokemon.isFullHp()).toBe(true);
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TACKLE);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the pokemon's move is protected against", async () => {
|
||||
game.override.moveset([MoveId.TACKLE]).enemyMoveset(MoveId.PROTECT);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.TACKLE);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TACKLE);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the pokemon's move fails because of type immunity", async () => {
|
||||
game.override.moveset([MoveId.TACKLE]).enemySpecies(SpeciesId.GASTLY);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.TACKLE);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TACKLE);
|
||||
});
|
||||
|
||||
test("ability is not applied if pokemon's type is the same as the move's type", async () => {
|
||||
game.override.moveset([MoveId.SPLASH]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
leadPokemon.summonData.types = [allMoves[MoveId.SPLASH].type];
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.LIBERO);
|
||||
});
|
||||
|
||||
test("ability is not applied if pokemon is terastallized", async () => {
|
||||
game.override.moveset([MoveId.SPLASH]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
leadPokemon.isTerastallized = true;
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.LIBERO);
|
||||
});
|
||||
|
||||
test("ability is not applied if pokemon uses struggle", async () => {
|
||||
game.override.moveset([MoveId.STRUGGLE]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.STRUGGLE);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.LIBERO);
|
||||
});
|
||||
|
||||
test("ability is not applied if the pokemon's move fails", async () => {
|
||||
game.override.moveset([MoveId.BURN_UP]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.BURN_UP);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.LIBERO);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the pokemon's Trick-or-Treat fails", async () => {
|
||||
game.override.moveset([MoveId.TRICK_OR_TREAT]).enemySpecies(SpeciesId.GASTLY);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.TRICK_OR_TREAT);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TRICK_OR_TREAT);
|
||||
});
|
||||
|
||||
test("ability applies correctly and the pokemon curses itself", async () => {
|
||||
game.override.moveset([MoveId.CURSE]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.CURSE);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.CURSE);
|
||||
expect(leadPokemon.getTag(BattlerTagType.CURSED)).not.toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
function testPokemonTypeMatchesDefaultMoveType(pokemon: PlayerPokemon, move: MoveId) {
|
||||
expect(pokemon.waveData.abilitiesApplied).toContain(AbilityId.LIBERO);
|
||||
expect(pokemon.getTypes()).toHaveLength(1);
|
||||
const pokemonType = PokemonType[pokemon.getTypes()[0]],
|
||||
moveType = PokemonType[allMoves[move].type];
|
||||
expect(pokemonType).toBe(moveType);
|
||||
}
|
272
test/abilities/protean-libero.test.ts
Normal file
272
test/abilities/protean-libero.test.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import { allMoves } from "#app/data/data-lists";
|
||||
import { PokemonType } from "#enums/pokemon-type";
|
||||
import type { PlayerPokemon } from "#app/field/pokemon";
|
||||
import { MoveResult } from "#enums/move-result";
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { BattlerTagType } from "#enums/battler-tag-type";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import GameManager from "#test/testUtils/gameManager";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BattleType } from "#enums/battle-type";
|
||||
import { BattlerIndex } from "#enums/battler-index";
|
||||
|
||||
describe("Abilities - Protean/Libero", () => {
|
||||
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
|
||||
.battleStyle("single")
|
||||
.ability(AbilityId.PROTEAN)
|
||||
.startingLevel(100)
|
||||
.moveset([MoveId.CURSE, MoveId.DIG, MoveId.SPLASH])
|
||||
.enemySpecies(SpeciesId.RATTATA)
|
||||
.enemyMoveset(MoveId.SPLASH);
|
||||
});
|
||||
|
||||
/**
|
||||
* Assert that the protean/libero ability triggered to change the user's type to
|
||||
* the type of its most recently used move.
|
||||
* Takes into account type overrides from effects.
|
||||
* @param pokemon - The {@linkcode PlayerPokemon} being checked.
|
||||
* @remarks
|
||||
* This will clear the given Pokemon's `abilitiesApplied` set after being called to allow for easier multi-turn testing.
|
||||
*/
|
||||
function expectTypeChange(pokemon: PlayerPokemon) {
|
||||
expect(pokemon.waveData.abilitiesApplied).toContainEqual(expect.toBeOneOf([AbilityId.PROTEAN, AbilityId.LIBERO]));
|
||||
const lastMove = allMoves[pokemon.getLastXMoves()[0].move]!;
|
||||
|
||||
const pokemonTypes = pokemon.getTypes().map(pt => PokemonType[pt]);
|
||||
const moveType = PokemonType[pokemon.getMoveType(lastMove)];
|
||||
expect(pokemonTypes).toEqual([moveType]);
|
||||
pokemon.waveData.abilitiesApplied.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the protean/libero ability did NOT trigger to change the user's type to
|
||||
* the type of its most recently used move.
|
||||
* Takes into account type overrides from effects.
|
||||
* @param pokemon - The {@linkcode PlayerPokemon} being checked.
|
||||
* @remarks
|
||||
* This will clear the given Pokemon's `abilitiesApplied` set after being called to allow for easier multi-turn testing.
|
||||
*/
|
||||
function expectNoTypeChange(pokemon: PlayerPokemon) {
|
||||
expect(pokemon.waveData.abilitiesApplied).not.toContainEqual(
|
||||
expect.toBeOneOf([AbilityId.PROTEAN, AbilityId.LIBERO]),
|
||||
);
|
||||
const lastMove = allMoves[pokemon.getLastXMoves()[0].move]!;
|
||||
|
||||
const pokemonTypes = pokemon.getTypes().map(pt => PokemonType[pt]);
|
||||
const moveType = PokemonType[pokemon.getMoveType(lastMove, true)];
|
||||
expect(pokemonTypes).not.toEqual([moveType]);
|
||||
pokemon.waveData.abilitiesApplied.clear();
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ name: "Protean", ability: AbilityId.PROTEAN },
|
||||
{ name: "Libero", ability: AbilityId.PROTEAN },
|
||||
])("$name should change the user's type to the type of the move being used", async ({ ability }) => {
|
||||
game.override.ability(ability);
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.field.getPlayerPokemon();
|
||||
|
||||
game.move.use(MoveId.SPLASH);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expectTypeChange(leadPokemon);
|
||||
});
|
||||
|
||||
// Test for Gen9+ functionality, we are using previous funcionality
|
||||
it.skip("should apply only once per switch in", async () => {
|
||||
game.override.moveset([MoveId.SPLASH, MoveId.AGILITY]);
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.BULBASAUR]);
|
||||
|
||||
const bulbasaur = game.field.getPlayerPokemon();
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expectTypeChange(bulbasaur);
|
||||
|
||||
game.move.select(MoveId.AGILITY);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expectNoTypeChange(bulbasaur);
|
||||
|
||||
// switch out and back in
|
||||
game.doSwitchPokemon(1);
|
||||
await game.toNextTurn();
|
||||
game.doSwitchPokemon(1);
|
||||
await game.toNextTurn();
|
||||
|
||||
expect(bulbasaur.isOnField()).toBe(true);
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expectTypeChange(bulbasaur);
|
||||
});
|
||||
|
||||
it.each<{ category: string; move?: MoveId; passive?: AbilityId; enemyMove?: MoveId }>([
|
||||
{ category: "Variable type Moves'", move: MoveId.WEATHER_BALL, passive: AbilityId.DROUGHT },
|
||||
{ category: "Type Change Abilities'", passive: AbilityId.REFRIGERATE },
|
||||
{ category: "Move-calling Moves'", move: MoveId.NATURE_POWER, passive: AbilityId.PSYCHIC_SURGE },
|
||||
{ category: "Ion Deluge's", enemyMove: MoveId.ION_DELUGE },
|
||||
{ category: "Electrify's", enemyMove: MoveId.ELECTRIFY },
|
||||
])(
|
||||
"should respect $category final type",
|
||||
async ({ move = MoveId.TACKLE, passive = AbilityId.NONE, enemyMove = MoveId.SPLASH }) => {
|
||||
game.override.passiveAbility(passive);
|
||||
await game.classicMode.startBattle([SpeciesId.LINOONE]); // Pure normal type for move overrides
|
||||
|
||||
const linoone = game.field.getPlayerPokemon();
|
||||
|
||||
game.move.use(move);
|
||||
await game.move.forceEnemyMove(enemyMove);
|
||||
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
|
||||
// We stop before running `TurnEndPhase` so that the effects of `BattlerTag`s (such as from Electrify)
|
||||
// are still active when checking the move's type
|
||||
await game.phaseInterceptor.to("TurnEndPhase", false);
|
||||
|
||||
expectTypeChange(linoone);
|
||||
},
|
||||
);
|
||||
|
||||
it.each<{ cause: string; move?: MoveId; passive?: AbilityId; enemyMove?: MoveId }>([
|
||||
{ cause: "misses", move: MoveId.FOCUS_BLAST },
|
||||
{ cause: "is protected against", enemyMove: MoveId.PROTECT },
|
||||
{ cause: "is ineffective", move: MoveId.EARTHQUAKE },
|
||||
{ cause: "matches only one of its types", move: MoveId.NIGHT_SLASH },
|
||||
{ cause: "is blocked by terrain", move: MoveId.SHADOW_SNEAK, passive: AbilityId.PSYCHIC_SURGE },
|
||||
])(
|
||||
"should still trigger if the user's move $cause",
|
||||
async ({ move = MoveId.TACKLE, passive = AbilityId.NONE, enemyMove = MoveId.SPLASH }) => {
|
||||
game.override.passiveAbility(passive).enemySpecies(SpeciesId.SKARMORY);
|
||||
await game.classicMode.startBattle([SpeciesId.MEOWSCARADA]);
|
||||
|
||||
vi.spyOn(allMoves[MoveId.FOCUS_BLAST], "accuracy", "get").mockReturnValue(0);
|
||||
|
||||
const meow = game.field.getPlayerPokemon();
|
||||
|
||||
game.move.use(move);
|
||||
await game.move.forceEnemyMove(enemyMove);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expectTypeChange(meow);
|
||||
},
|
||||
);
|
||||
|
||||
it.each<{ cause: string; move?: MoveId; tera?: boolean; passive?: AbilityId }>([
|
||||
{ cause: "user is terastallized to any type", tera: true },
|
||||
{ cause: "user uses Struggle", move: MoveId.STRUGGLE },
|
||||
{ cause: "the user's move is blocked by weather", move: MoveId.FIRE_BLAST, passive: AbilityId.PRIMORDIAL_SEA },
|
||||
{ cause: "the user's move fails", move: MoveId.BURN_UP },
|
||||
])("should not apply if $cause", async ({ move = MoveId.TACKLE, tera = false, passive = AbilityId.NONE }) => {
|
||||
game.override.enemyPassiveAbility(passive);
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const karp = game.field.getPlayerPokemon();
|
||||
|
||||
karp.teraType = PokemonType.STEEL;
|
||||
|
||||
game.move.use(move, BattlerIndex.PLAYER, undefined, tera);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expectNoTypeChange(karp);
|
||||
});
|
||||
|
||||
it("should not apply if user is already the move's type", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const karp = game.field.getPlayerPokemon();
|
||||
|
||||
game.move.use(MoveId.WATERFALL);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(karp.waveData.abilitiesApplied.size).toBe(0);
|
||||
expect(karp.getTypes()).toEqual([allMoves[MoveId.WATERFALL].type]);
|
||||
});
|
||||
|
||||
it.each<{ moveName: string; move: MoveId }>([
|
||||
{ moveName: "Roar", move: MoveId.ROAR },
|
||||
{ moveName: "Whirlwind", move: MoveId.WHIRLWIND },
|
||||
{ moveName: "Forest's Curse", move: MoveId.FORESTS_CURSE },
|
||||
{ moveName: "Trick-or-Treat", move: MoveId.TRICK_OR_TREAT },
|
||||
])("should still apply if the user's $moveName fails", async ({ move }) => {
|
||||
game.override.battleType(BattleType.TRAINER).enemySpecies(SpeciesId.TREVENANT); // ghost/grass makes both moves fail
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.field.getPlayerPokemon();
|
||||
|
||||
game.move.use(move);
|
||||
// KO all off-field opponents for Whirlwind and co.
|
||||
for (const enemyMon of game.scene.getEnemyParty()) {
|
||||
if (!enemyMon.isActive()) {
|
||||
enemyMon.hp = 0;
|
||||
}
|
||||
}
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expectTypeChange(leadPokemon);
|
||||
});
|
||||
|
||||
it("should trigger on the first turn of charging moves", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const karp = game.field.getPlayerPokemon();
|
||||
|
||||
game.move.select(MoveId.DIG);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expectTypeChange(karp);
|
||||
|
||||
await game.toEndOfTurn();
|
||||
expect(karp.waveData.abilitiesApplied).not.toContain(AbilityId.PROTEAN);
|
||||
});
|
||||
|
||||
it("should cause the user to cast Ghost-type Curse on itself", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const karp = game.field.getPlayerPokemon();
|
||||
expect(karp.isOfType(PokemonType.GHOST)).toBe(false);
|
||||
|
||||
game.move.select(MoveId.CURSE);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expectTypeChange(karp);
|
||||
expect(karp.getHpRatio(true)).toBeCloseTo(0.25);
|
||||
expect(karp.getTag(BattlerTagType.CURSED)).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not trigger during Focus Punch's start-of-turn message or being interrupted", async () => {
|
||||
game.override.moveset(MoveId.FOCUS_PUNCH).enemyMoveset(MoveId.ABSORB);
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const karp = game.field.getPlayerPokemon();
|
||||
expect(karp.isOfType(PokemonType.FIGHTING)).toBe(false);
|
||||
|
||||
game.move.select(MoveId.FOCUS_PUNCH);
|
||||
|
||||
await game.phaseInterceptor.to("MessagePhase");
|
||||
expect(karp.isOfType(PokemonType.FIGHTING)).toBe(false);
|
||||
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expectNoTypeChange(karp);
|
||||
expect(karp.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
|
||||
});
|
||||
});
|
@ -1,297 +0,0 @@
|
||||
import { allMoves } from "#app/data/data-lists";
|
||||
import { PokemonType } from "#enums/pokemon-type";
|
||||
import { Weather } from "#app/data/weather";
|
||||
import type { PlayerPokemon } from "#app/field/pokemon";
|
||||
import { TurnEndPhase } from "#app/phases/turn-end-phase";
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { BattlerTagType } from "#enums/battler-tag-type";
|
||||
import { BiomeId } from "#enums/biome-id";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import { WeatherType } from "#enums/weather-type";
|
||||
import GameManager from "#test/testUtils/gameManager";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
describe("Abilities - Protean", () => {
|
||||
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
|
||||
.battleStyle("single")
|
||||
.ability(AbilityId.PROTEAN)
|
||||
.startingLevel(100)
|
||||
.enemySpecies(SpeciesId.RATTATA)
|
||||
.enemyMoveset(MoveId.ENDURE);
|
||||
});
|
||||
|
||||
test("ability applies and changes a pokemon's type", async () => {
|
||||
game.override.moveset([MoveId.SPLASH]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.SPLASH);
|
||||
});
|
||||
|
||||
// Test for Gen9+ functionality, we are using previous funcionality
|
||||
test.skip("ability applies only once per switch in", async () => {
|
||||
game.override.moveset([MoveId.SPLASH, MoveId.AGILITY]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.BULBASAUR]);
|
||||
|
||||
let leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.SPLASH);
|
||||
|
||||
game.move.select(MoveId.AGILITY);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
expect(leadPokemon.waveData.abilitiesApplied).toContain(AbilityId.PROTEAN);
|
||||
const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]];
|
||||
const moveType = PokemonType[allMoves[MoveId.AGILITY].type];
|
||||
expect(leadPokemonType).not.toBe(moveType);
|
||||
|
||||
await game.toNextTurn();
|
||||
game.doSwitchPokemon(1);
|
||||
await game.toNextTurn();
|
||||
game.doSwitchPokemon(1);
|
||||
await game.toNextTurn();
|
||||
|
||||
leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.SPLASH);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the pokemon's move has a variable type", async () => {
|
||||
game.override.moveset([MoveId.WEATHER_BALL]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.scene.arena.weather = new Weather(WeatherType.SUNNY);
|
||||
game.move.select(MoveId.WEATHER_BALL);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
expect(leadPokemon.waveData.abilitiesApplied).toContain(AbilityId.PROTEAN);
|
||||
expect(leadPokemon.getTypes()).toHaveLength(1);
|
||||
const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]],
|
||||
moveType = PokemonType[PokemonType.FIRE];
|
||||
expect(leadPokemonType).toBe(moveType);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the type has changed by another ability", async () => {
|
||||
game.override.moveset([MoveId.TACKLE]).passiveAbility(AbilityId.REFRIGERATE);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.TACKLE);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
expect(leadPokemon.waveData.abilitiesApplied).toContain(AbilityId.PROTEAN);
|
||||
expect(leadPokemon.getTypes()).toHaveLength(1);
|
||||
const leadPokemonType = PokemonType[leadPokemon.getTypes()[0]],
|
||||
moveType = PokemonType[PokemonType.ICE];
|
||||
expect(leadPokemonType).toBe(moveType);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the pokemon's move calls another move", async () => {
|
||||
game.override.moveset([MoveId.NATURE_POWER]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.scene.arena.biomeType = BiomeId.MOUNTAIN;
|
||||
game.move.select(MoveId.NATURE_POWER);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.AIR_SLASH);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the pokemon's move is delayed / charging", async () => {
|
||||
game.override.moveset([MoveId.DIG]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.DIG);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.DIG);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the pokemon's move misses", async () => {
|
||||
game.override.moveset([MoveId.TACKLE]).enemyMoveset(MoveId.SPLASH);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.TACKLE);
|
||||
await game.move.forceMiss();
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
const enemyPokemon = game.scene.getEnemyPokemon()!;
|
||||
expect(enemyPokemon.isFullHp()).toBe(true);
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TACKLE);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the pokemon's move is protected against", async () => {
|
||||
game.override.moveset([MoveId.TACKLE]).enemyMoveset(MoveId.PROTECT);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.TACKLE);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TACKLE);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the pokemon's move fails because of type immunity", async () => {
|
||||
game.override.moveset([MoveId.TACKLE]).enemySpecies(SpeciesId.GASTLY);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.TACKLE);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TACKLE);
|
||||
});
|
||||
|
||||
test("ability is not applied if pokemon's type is the same as the move's type", async () => {
|
||||
game.override.moveset([MoveId.SPLASH]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
leadPokemon.summonData.types = [allMoves[MoveId.SPLASH].type];
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.PROTEAN);
|
||||
});
|
||||
|
||||
test("ability is not applied if pokemon is terastallized", async () => {
|
||||
game.override.moveset([MoveId.SPLASH]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
leadPokemon.isTerastallized = true;
|
||||
|
||||
game.move.select(MoveId.SPLASH);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.PROTEAN);
|
||||
});
|
||||
|
||||
test("ability is not applied if pokemon uses struggle", async () => {
|
||||
game.override.moveset([MoveId.STRUGGLE]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.STRUGGLE);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.PROTEAN);
|
||||
});
|
||||
|
||||
test("ability is not applied if the pokemon's move fails", async () => {
|
||||
game.override.moveset([MoveId.BURN_UP]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.BURN_UP);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
expect(leadPokemon.waveData.abilitiesApplied).not.toContain(AbilityId.PROTEAN);
|
||||
});
|
||||
|
||||
test("ability applies correctly even if the pokemon's Trick-or-Treat fails", async () => {
|
||||
game.override.moveset([MoveId.TRICK_OR_TREAT]).enemySpecies(SpeciesId.GASTLY);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.TRICK_OR_TREAT);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.TRICK_OR_TREAT);
|
||||
});
|
||||
|
||||
test("ability applies correctly and the pokemon curses itself", async () => {
|
||||
game.override.moveset([MoveId.CURSE]);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
|
||||
|
||||
const leadPokemon = game.scene.getPlayerPokemon()!;
|
||||
expect(leadPokemon).not.toBe(undefined);
|
||||
|
||||
game.move.select(MoveId.CURSE);
|
||||
await game.phaseInterceptor.to(TurnEndPhase);
|
||||
|
||||
testPokemonTypeMatchesDefaultMoveType(leadPokemon, MoveId.CURSE);
|
||||
expect(leadPokemon.getTag(BattlerTagType.CURSED)).not.toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
function testPokemonTypeMatchesDefaultMoveType(pokemon: PlayerPokemon, move: MoveId) {
|
||||
expect(pokemon.waveData.abilitiesApplied).toContain(AbilityId.PROTEAN);
|
||||
expect(pokemon.getTypes()).toHaveLength(1);
|
||||
const pokemonType = PokemonType[pokemon.getTypes()[0]],
|
||||
moveType = PokemonType[allMoves[move].type];
|
||||
expect(pokemonType).toBe(moveType);
|
||||
}
|
109
test/moves/ability-ignore-moves.test.ts
Normal file
109
test/moves/ability-ignore-moves.test.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { BattlerIndex } from "#enums/battler-index";
|
||||
import { RandomMoveAttr } from "#app/data/moves/move";
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import GameManager from "#test/testUtils/gameManager";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("Moves - Ability-Ignoring Moves", () => {
|
||||
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
|
||||
.moveset([MoveId.MOONGEIST_BEAM, MoveId.SUNSTEEL_STRIKE, MoveId.PHOTON_GEYSER, MoveId.METRONOME])
|
||||
.ability(AbilityId.BALL_FETCH)
|
||||
.startingLevel(200)
|
||||
.battleStyle("single")
|
||||
.criticalHits(false)
|
||||
.enemySpecies(SpeciesId.MAGIKARP)
|
||||
.enemyAbility(AbilityId.STURDY)
|
||||
.enemyMoveset(MoveId.SPLASH);
|
||||
});
|
||||
|
||||
it.each<{ name: string; move: MoveId }>([
|
||||
{ name: "Sunsteel Strike", move: MoveId.SUNSTEEL_STRIKE },
|
||||
{ name: "Moongeist Beam", move: MoveId.MOONGEIST_BEAM },
|
||||
{ name: "Photon Geyser", move: MoveId.PHOTON_GEYSER },
|
||||
])("$name should ignore enemy abilities during move use", async ({ move }) => {
|
||||
await game.classicMode.startBattle([SpeciesId.NECROZMA]);
|
||||
|
||||
const player = game.field.getPlayerPokemon();
|
||||
const enemy = game.field.getEnemyPokemon();
|
||||
|
||||
game.move.select(move);
|
||||
await game.phaseInterceptor.to("MoveEffectPhase");
|
||||
|
||||
expect(game.scene.arena.ignoreAbilities).toBe(true);
|
||||
expect(game.scene.arena.ignoringEffectSource).toBe(player.getBattlerIndex());
|
||||
|
||||
await game.toEndOfTurn();
|
||||
expect(game.scene.arena.ignoreAbilities).toBe(false);
|
||||
expect(enemy.isFainted()).toBe(true);
|
||||
});
|
||||
|
||||
it("should not ignore enemy abilities when called by Metronome", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.MILOTIC]);
|
||||
vi.spyOn(RandomMoveAttr.prototype, "getMoveOverride").mockReturnValue(MoveId.PHOTON_GEYSER);
|
||||
|
||||
const enemy = game.field.getEnemyPokemon();
|
||||
game.move.select(MoveId.METRONOME);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(enemy.isFainted()).toBe(false);
|
||||
expect(game.field.getPlayerPokemon().getLastXMoves()[0].move).toBe(MoveId.PHOTON_GEYSER);
|
||||
});
|
||||
|
||||
it("should not ignore enemy abilities when called by Mirror Move", async () => {
|
||||
game.override.moveset(MoveId.MIRROR_MOVE).enemyMoveset(MoveId.SUNSTEEL_STRIKE);
|
||||
|
||||
await game.classicMode.startBattle([SpeciesId.MILOTIC]);
|
||||
|
||||
const enemy = game.field.getEnemyPokemon();
|
||||
game.move.select(MoveId.MIRROR_MOVE);
|
||||
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(enemy.isFainted()).toBe(false);
|
||||
expect(game.scene.getPlayerPokemon()?.getLastXMoves()[0].move).toBe(MoveId.SUNSTEEL_STRIKE);
|
||||
});
|
||||
|
||||
// TODO: Verify this behavior on cart
|
||||
it("should ignore enemy abilities when called by Instruct", async () => {
|
||||
game.override.moveset([MoveId.SUNSTEEL_STRIKE, MoveId.INSTRUCT]).battleStyle("double");
|
||||
await game.classicMode.startBattle([SpeciesId.SOLGALEO, SpeciesId.LUNALA]);
|
||||
|
||||
const solgaleo = game.field.getPlayerPokemon();
|
||||
|
||||
game.move.select(MoveId.SUNSTEEL_STRIKE, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
|
||||
game.move.select(MoveId.INSTRUCT, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
|
||||
|
||||
await game.phaseInterceptor.to("MoveEffectPhase"); // initial attack
|
||||
await game.phaseInterceptor.to("MoveEffectPhase"); // instruct
|
||||
await game.phaseInterceptor.to("MoveEffectPhase"); // instructed move use
|
||||
|
||||
expect(game.scene.arena.ignoreAbilities).toBe(true);
|
||||
expect(game.scene.arena.ignoringEffectSource).toBe(solgaleo.getBattlerIndex());
|
||||
|
||||
await game.toEndOfTurn();
|
||||
|
||||
// Both the initial and redirected instruct use ignored sturdy
|
||||
const [enemy1, enemy2] = game.scene.getEnemyField();
|
||||
expect(enemy1.isFainted()).toBe(true);
|
||||
expect(enemy2.isFainted()).toBe(true);
|
||||
});
|
||||
});
|
122
test/moves/first-attack-double-power.test.ts
Normal file
122
test/moves/first-attack-double-power.test.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { BattlerIndex } from "#enums/battler-index";
|
||||
import { allMoves } from "#app/data/data-lists";
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { BattleType } from "#enums/battle-type";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import GameManager from "#test/testUtils/gameManager";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
|
||||
import { MoveUseMode } from "#enums/move-use-mode";
|
||||
|
||||
describe("Moves - Fishious Rend & Bolt Beak", () => {
|
||||
let phaserGame: Phaser.Game;
|
||||
let game: GameManager;
|
||||
let powerSpy: MockInstance;
|
||||
|
||||
beforeAll(() => {
|
||||
phaserGame = new Phaser.Game({
|
||||
type: Phaser.HEADLESS,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
game.phaseInterceptor.restoreOg();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
game = new GameManager(phaserGame);
|
||||
game.override
|
||||
.ability(AbilityId.STURDY)
|
||||
.battleStyle("single")
|
||||
.battleType(BattleType.TRAINER)
|
||||
.criticalHits(false)
|
||||
.enemyLevel(100)
|
||||
.enemySpecies(SpeciesId.DRACOVISH)
|
||||
.enemyAbility(AbilityId.BALL_FETCH)
|
||||
.enemyMoveset(MoveId.SPLASH);
|
||||
|
||||
powerSpy = vi.spyOn(allMoves[MoveId.BOLT_BEAK], "calculateBattlePower");
|
||||
});
|
||||
|
||||
it.each<{ name: string; move: MoveId }>([
|
||||
{ name: "Bolt Beak", move: MoveId.BOLT_BEAK },
|
||||
{ name: "Fishious Rend", move: MoveId.FISHIOUS_REND },
|
||||
])("$name should double power if the user moves before the target", async ({ move }) => {
|
||||
powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower");
|
||||
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
|
||||
|
||||
// turn 1: enemy, then player (no boost)
|
||||
game.move.use(move);
|
||||
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
|
||||
await game.toNextTurn();
|
||||
|
||||
expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power);
|
||||
|
||||
// turn 2: player, then enemy (boost)
|
||||
game.move.use(move);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power * 2);
|
||||
});
|
||||
|
||||
it("should only consider the selected target in Double Battles", async () => {
|
||||
game.override.battleStyle("double");
|
||||
await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.MILOTIC]);
|
||||
|
||||
// Use move after everyone but P1 and enemy 1 have already moved
|
||||
game.move.use(MoveId.BOLT_BEAK, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
|
||||
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER_2);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(powerSpy).toHaveLastReturnedWith(allMoves[MoveId.BOLT_BEAK].power * 2);
|
||||
});
|
||||
|
||||
it("should double power on the turn the target switches in", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
|
||||
|
||||
game.move.use(MoveId.BOLT_BEAK);
|
||||
game.forceEnemyToSwitch();
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(powerSpy).toHaveLastReturnedWith(allMoves[MoveId.BOLT_BEAK].power * 2);
|
||||
});
|
||||
|
||||
it("should double power on forced switch-induced sendouts", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
|
||||
|
||||
game.move.use(MoveId.BOLT_BEAK);
|
||||
await game.move.forceEnemyMove(MoveId.U_TURN);
|
||||
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(powerSpy).toHaveLastReturnedWith(allMoves[MoveId.BOLT_BEAK].power * 2);
|
||||
});
|
||||
|
||||
it.each<{ type: string; allyMove: MoveId }>([
|
||||
{ type: "a Dancer-induced", allyMove: MoveId.FIERY_DANCE },
|
||||
{ type: "an Instructed", allyMove: MoveId.INSTRUCT },
|
||||
])("should double power if $type move is used as the target's first action that turn", async ({ allyMove }) => {
|
||||
game.override.battleStyle("double").enemyAbility(AbilityId.DANCER);
|
||||
powerSpy = vi.spyOn(allMoves[MoveId.FISHIOUS_REND], "calculateBattlePower");
|
||||
await game.classicMode.startBattle([SpeciesId.DRACOVISH, SpeciesId.ARCTOZOLT]);
|
||||
|
||||
// Simulate enemy having used splash last turn to allow Instruct to copy it
|
||||
const enemy = game.field.getEnemyPokemon();
|
||||
enemy.pushMoveHistory({
|
||||
move: MoveId.SPLASH,
|
||||
targets: [BattlerIndex.ENEMY],
|
||||
turn: game.scene.currentBattle.turn - 1,
|
||||
useMode: MoveUseMode.NORMAL,
|
||||
});
|
||||
|
||||
game.move.use(MoveId.FISHIOUS_REND, BattlerIndex.PLAYER, BattlerIndex.ENEMY);
|
||||
game.move.use(allyMove, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(powerSpy).toHaveLastReturnedWith(allMoves[MoveId.FISHIOUS_REND].power);
|
||||
});
|
||||
});
|
@ -1,61 +0,0 @@
|
||||
import { allMoves } from "#app/data/data-lists";
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import GameManager from "#test/testUtils/gameManager";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("Moves - Moongeist Beam", () => {
|
||||
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
|
||||
.moveset([MoveId.MOONGEIST_BEAM, MoveId.METRONOME])
|
||||
.ability(AbilityId.BALL_FETCH)
|
||||
.startingLevel(200)
|
||||
.battleStyle("single")
|
||||
.criticalHits(false)
|
||||
.enemySpecies(SpeciesId.MAGIKARP)
|
||||
.enemyAbility(AbilityId.STURDY)
|
||||
.enemyMoveset(MoveId.SPLASH);
|
||||
});
|
||||
|
||||
// Also covers Photon Geyser and Sunsteel Strike
|
||||
it("should ignore enemy abilities", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.MILOTIC]);
|
||||
|
||||
const enemy = game.scene.getEnemyPokemon()!;
|
||||
|
||||
game.move.select(MoveId.MOONGEIST_BEAM);
|
||||
await game.phaseInterceptor.to("BerryPhase");
|
||||
|
||||
expect(enemy.isFainted()).toBe(true);
|
||||
});
|
||||
|
||||
// Also covers Photon Geyser and Sunsteel Strike
|
||||
it("should not ignore enemy abilities when called by another move, such as metronome", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.MILOTIC]);
|
||||
vi.spyOn(allMoves[MoveId.METRONOME].getAttrs("RandomMoveAttr")[0], "getMoveOverride").mockReturnValue(
|
||||
MoveId.MOONGEIST_BEAM,
|
||||
);
|
||||
|
||||
game.move.select(MoveId.METRONOME);
|
||||
await game.phaseInterceptor.to("BerryPhase");
|
||||
|
||||
expect(game.scene.getEnemyPokemon()!.isFainted()).toBe(false);
|
||||
expect(game.scene.getPlayerPokemon()!.getLastXMoves()[0].move).toBe(MoveId.MOONGEIST_BEAM);
|
||||
});
|
||||
});
|
69
test/moves/payback.test.ts
Normal file
69
test/moves/payback.test.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { allMoves } from "#app/data/data-lists";
|
||||
import { AbilityId } from "#enums/ability-id";
|
||||
import { BattlerIndex } from "#enums/battler-index";
|
||||
import { MoveId } from "#enums/move-id";
|
||||
import { PokeballType } from "#enums/pokeball";
|
||||
import { SpeciesId } from "#enums/species-id";
|
||||
import GameManager from "#test/testUtils/gameManager";
|
||||
import Phaser from "phaser";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
|
||||
|
||||
describe("Move - Payback", () => {
|
||||
let phaserGame: Phaser.Game;
|
||||
let game: GameManager;
|
||||
let powerSpy: MockInstance;
|
||||
|
||||
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)
|
||||
.enemyLevel(100)
|
||||
.enemySpecies(SpeciesId.DRACOVISH)
|
||||
.enemyAbility(AbilityId.BALL_FETCH)
|
||||
.enemyMoveset(MoveId.SPLASH)
|
||||
.startingModifier([{ name: "POKEBALL", count: 5 }]);
|
||||
|
||||
powerSpy = vi.spyOn(allMoves[MoveId.PAYBACK], "calculateBattlePower");
|
||||
});
|
||||
|
||||
it("should double power if the user moves after the target", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
|
||||
|
||||
// turn 1: enemy, then player (boost)
|
||||
game.move.use(MoveId.PAYBACK);
|
||||
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
|
||||
await game.toNextTurn();
|
||||
|
||||
expect(powerSpy).toHaveLastReturnedWith(allMoves[MoveId.PAYBACK].power * 2);
|
||||
|
||||
// turn 2: player, then enemy (no boost)
|
||||
game.move.use(MoveId.PAYBACK);
|
||||
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(powerSpy).toHaveLastReturnedWith(allMoves[MoveId.PAYBACK].power);
|
||||
});
|
||||
|
||||
// TODO: Enable test once ability to force catch failure is added
|
||||
it.todo("should trigger for enemies on player failed ball catch", async () => {
|
||||
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
|
||||
|
||||
game.doThrowPokeball(PokeballType.POKEBALL);
|
||||
await game.move.forceEnemyMove(MoveId.PAYBACK);
|
||||
await game.toEndOfTurn();
|
||||
|
||||
expect(powerSpy).toHaveLastReturnedWith(allMoves[MoveId.PAYBACK].power * 2);
|
||||
});
|
||||
});
|
@ -56,6 +56,7 @@ import TextInterceptor from "#test/testUtils/TextInterceptor";
|
||||
import { AES, enc } from "crypto-js";
|
||||
import fs from "node:fs";
|
||||
import { expect, vi } from "vitest";
|
||||
import type { PokeballType } from "#enums/pokeball";
|
||||
|
||||
/**
|
||||
* Class to manage the game state and transitions between phases.
|
||||
@ -507,9 +508,9 @@ export default class GameManager {
|
||||
/**
|
||||
* Select the BALL option from the command menu, then press Action; in the BALL
|
||||
* menu, select a pokéball type and press Action again to throw it.
|
||||
* @param ballIndex - The index of the pokeball to throw
|
||||
* @param ballIndex - The {@linkcode PokeballType} to throw
|
||||
*/
|
||||
public doThrowPokeball(ballIndex: number) {
|
||||
public doThrowPokeball(ballIndex: PokeballType) {
|
||||
this.onNextPrompt("CommandPhase", UiMode.COMMAND, () => {
|
||||
(this.scene.ui.getHandler() as CommandUiHandler).setCursor(1);
|
||||
(this.scene.ui.getHandler() as CommandUiHandler).processInput(Button.ACTION);
|
||||
|
Loading…
Reference in New Issue
Block a user