Added toBeConfused matcher

This commit is contained in:
Bertie690 2025-08-05 17:33:58 -04:00
parent 3b36ab17e4
commit 4885a6abc5
22 changed files with 953 additions and 614 deletions

View File

@ -1152,13 +1152,16 @@ export class GravityTag extends SerializableArenaTag {
onAdd(_arena: Arena): void {
globalScene.phaseManager.queueMessage(i18next.t("arenaTag:gravityOnAdd"));
// Remove all flying-related effects from all on-field Pokemon.
globalScene.getField(true).forEach(pokemon => {
if (pokemon !== null) {
pokemon.removeTag(BattlerTagType.FLOATING);
pokemon.removeTag(BattlerTagType.TELEKINESIS);
if (pokemon.getTag(BattlerTagType.FLYING)) {
pokemon.addTag(BattlerTagType.INTERRUPTED);
}
pokemon.removeTag(BattlerTagType.FLOATING);
pokemon.removeTag(BattlerTagType.TELEKINESIS);
if (pokemon.getTag(BattlerTagType.FLYING)) {
pokemon.removeTag(BattlerTagType.FLYING);
// TODO: This is an extremely poor way of handling move interruption
pokemon.addTag(BattlerTagType.INTERRUPTED);
}
});
}

View File

@ -736,6 +736,10 @@ export class FlinchedTag extends BattlerTag {
}
}
/**
* Tag to cancel the target's action when knocked out of a flying move by Smack Down or Gravity.
*/
// TODO: This is not a very good way to cancel a semi invulnerable turn
export class InterruptedTag extends BattlerTag {
public override readonly tagType = BattlerTagType.INTERRUPTED;
constructor(sourceMove: MoveId) {
@ -765,7 +769,7 @@ export class InterruptedTag extends BattlerTag {
}
/**
* BattlerTag that represents the {@link https://bulbapedia.bulbagarden.net/wiki/Confusion_(status_condition) Confusion} status condition
* BattlerTag that represents the {@link https://bulbapedia.bulbagarden.net/wiki/Confusion_(status_condition) | Confusion} status condition
*/
export class ConfusedTag extends SerializableBattlerTag {
public override readonly tagType = BattlerTagType.CONFUSED;
@ -774,8 +778,9 @@ export class ConfusedTag extends SerializableBattlerTag {
}
canAdd(pokemon: Pokemon): boolean {
const blockedByTerrain = pokemon.isGrounded() && globalScene.arena.terrain?.terrainType === TerrainType.MISTY;
const blockedByTerrain = pokemon.isGrounded() && globalScene.arena.getTerrainType() === TerrainType.MISTY;
if (blockedByTerrain) {
// TODO: this should not trigger if the current move is an attacking move
pokemon.queueStatusImmuneMessage(false, TerrainType.MISTY);
return false;
}

View File

@ -5356,13 +5356,11 @@ export class VariableMoveTypeMultiplierAttr extends MoveAttr {
}
export class NeutralDamageAgainstFlyingTypeMultiplierAttr extends VariableMoveTypeMultiplierAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!target.getTag(BattlerTagType.IGNORE_FLYING)) {
const multiplier = args[0] as NumberHolder;
//When a flying type is hit, the first hit is always 1x multiplier.
if (target.isOfType(PokemonType.FLYING)) {
multiplier.value = 1;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: [NumberHolder]): boolean {
if (!target.isGrounded(true) && target.isOfType(PokemonType.FLYING)) {
const multiplier = args[0];
// When a flying type is hit, the first hit is always 1x multiplier.
multiplier.value = 1;
return true;
}
@ -5601,13 +5599,13 @@ export class AddBattlerTagAttr extends MoveEffectAttr {
protected cancelOnFail: boolean;
private failOnOverlap: boolean;
constructor(tagType: BattlerTagType, selfTarget: boolean = false, failOnOverlap: boolean = false, turnCountMin: number = 0, turnCountMax?: number, lastHitOnly: boolean = false) {
constructor(tagType: BattlerTagType, selfTarget = false, failOnOverlap = false, turnCountMin: number = 0, turnCountMax = turnCountMin, lastHitOnly = false) {
super(selfTarget, { lastHitOnly: lastHitOnly });
this.tagType = tagType;
this.turnCountMin = turnCountMin;
this.turnCountMax = turnCountMax !== undefined ? turnCountMax : turnCountMin;
this.failOnOverlap = !!failOnOverlap;
this.turnCountMax = turnCountMax;
this.failOnOverlap = failOnOverlap;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
@ -5617,13 +5615,14 @@ export class AddBattlerTagAttr extends MoveEffectAttr {
const moveChance = this.getMoveChance(user, target, move, this.selfTarget, true);
if (moveChance < 0 || moveChance === 100 || user.randBattleSeedInt(100) < moveChance) {
return (this.selfTarget ? user : target).addTag(this.tagType, user.randBattleSeedIntRange(this.turnCountMin, this.turnCountMax), move.id, user.id);
return (this.selfTarget ? user : target).addTag(this.tagType, user.randBattleSeedIntRange(this.turnCountMin, this.turnCountMax), move.id, user.id);
}
return false;
}
getCondition(): MoveConditionFunc | null {
// TODO: This should consider whether the tag can be added
return this.failOnOverlap
? (user, target, move) => !(this.selfTarget ? user : target).getTag(this.tagType)
: null;
@ -5701,8 +5700,10 @@ export class LeechSeedAttr extends AddBattlerTagAttr {
}
/**
* Adds the appropriate battler tag for Smack Down and Thousand arrows
* @extends AddBattlerTagAttr
* Attribute to add the {@linkcode BattlerTagType.IGNORE_FLYING | IGNORE_FLYING} battler tag to the target
* and remove any prior sources of ungroundedness.
*
* Does nothing if the target was not already ungrounded.
*/
export class FallDownAttr extends AddBattlerTagAttr {
constructor() {
@ -5710,18 +5711,35 @@ export class FallDownAttr extends AddBattlerTagAttr {
}
/**
* Adds Grounded Tag to the target and checks if fallDown message should be displayed
* @param user the {@linkcode Pokemon} using the move
* @param target the {@linkcode Pokemon} targeted by the move
* @param move the {@linkcode Move} invoking this effect
* Add `GroundedTag` to the target, remove all prior sources of ungroundedness
* and display a message.
* @param user - The {@linkcode Pokemon} using the move
* @param target - The {@linkcode Pokemon} targeted by the move
* @param move - The {@linkcode Move} invoking this effect
* @param args n/a
* @returns `true` if the effect successfully applies; `false` otherwise
* @returns Whether the target was successfully brought down to earth.
*
*/
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!target.isGrounded()) {
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:fallDown", { targetPokemonName: getPokemonNameWithAffix(target) }));
apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean {
// Smack down and similar only add their tag if the target is already ungrounded,
// barring any prior semi-invulnerability.
if (target.isGrounded(true)) {
return false;
}
return super.apply(user, target, move, args);
// Remove the target's prior sources of ungroundedness.
// NB: These effects cannot simply be part of the tag's `onAdd` effect as Ingrain also adds the tag
// but does not remove Telekinesis' accuracy boost
target.removeTag(BattlerTagType.FLOATING);
target.removeTag(BattlerTagType.TELEKINESIS);
if (target.getTag(BattlerTagType.FLYING)) {
target.removeTag(BattlerTagType.FLYING);
// TODO: This is an extremely poor way of handling move interruption
target.addTag(BattlerTagType.INTERRUPTED);
}
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:fallDown", { targetPokemonName: getPokemonNameWithAffix(target) }));
return super.apply(user, target, move, _args);
}
}
@ -5825,6 +5843,7 @@ export class CurseAttr extends MoveEffectAttr {
}
}
// TODO: Delete this and make mortal spin use `RemoveBattlerTagAttr`
export class LapseBattlerTagAttr extends MoveEffectAttr {
public tagTypes: BattlerTagType[];
@ -8048,7 +8067,12 @@ const phaseForcedSlower = (phase: MovePhase, target: Pokemon, trickRoom: boolean
return phase.isForcedLast() && slower;
};
const failOnGravityCondition: MoveConditionFunc = (user, target, move) => !globalScene.arena.getTag(ArenaTagType.GRAVITY);
// #region Condition functions
// TODO: This needs to become unselectable, not merely fail
const failOnGravityCondition: MoveConditionFunc = () => !globalScene.arena.getTag(ArenaTagType.GRAVITY);
const failOnGroundedCondition: MoveConditionFunc = (_user, target) => !target.getTag(BattlerTagType.IGNORE_FLYING);
const failOnBossCondition: MoveConditionFunc = (user, target, move) => !target.isBossImmune();
@ -8079,6 +8103,10 @@ const failIfGhostTypeCondition: MoveConditionFunc = (user: Pokemon, target: Poke
const failIfNoTargetHeldItemsCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => target.getHeldItems().filter(i => i.isTransferable)?.length > 0;
const unknownTypeCondition: MoveConditionFunc = (user, target, move) => !user.getTypes().includes(PokemonType.UNKNOWN);
// #endregion Condition functions
const attackedByItemMessageFunc = (user: Pokemon, target: Pokemon, move: Move) => {
const heldItems = target.getHeldItems().filter(i => i.isTransferable);
if (heldItems.length === 0) {
@ -8260,9 +8288,6 @@ export class ExposedMoveAttr extends AddBattlerTagAttr {
}
}
const unknownTypeCondition: MoveConditionFunc = (user, target, move) => !user.getTypes().includes(PokemonType.UNKNOWN);
export type MoveTargetSet = {
targets: BattlerIndex[];
multiple: boolean;
@ -8912,7 +8937,6 @@ export function initMoves() {
.ignoresProtect()
/* Transform:
* Does not copy the target's rage fist hit count
* Does not copy the target's volatile status conditions (ie BattlerTags)
* Renders user typeless when copying typeless opponent (should revert to original typing)
*/
.edgeCase(),
@ -9359,6 +9383,7 @@ export function initMoves() {
.attr(RandomMovesetMoveAttr, invalidAssistMoves, true),
new SelfStatusMove(MoveId.INGRAIN, PokemonType.GRASS, -1, 20, -1, 0, 3)
.attr(AddBattlerTagAttr, BattlerTagType.INGRAIN, true, true)
// NB: We add IGNORE_FLYING and remove floating tag directly to avoid removing Telekinesis' accuracy boost
.attr(AddBattlerTagAttr, BattlerTagType.IGNORE_FLYING, true, true)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLOATING ], true),
new AttackMove(MoveId.SUPERPOWER, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 120, 100, 5, -1, 0, 3)
@ -9616,9 +9641,9 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.ROOSTED, true, false)
.triageMove(),
new StatusMove(MoveId.GRAVITY, PokemonType.PSYCHIC, -1, 5, -1, 0, 4)
.ignoresProtect()
.attr(AddArenaTagAttr, ArenaTagType.GRAVITY, 5)
.target(MoveTarget.BOTH_SIDES),
.target(MoveTarget.BOTH_SIDES)
.ignoresProtect(),
new StatusMove(MoveId.MIRACLE_EYE, PokemonType.PSYCHIC, -1, 40, -1, 0, 4)
.attr(ExposedMoveAttr, BattlerTagType.IGNORE_DARK)
.ignoresSubstitute()
@ -9748,7 +9773,8 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.AQUA_RING, true, true),
new SelfStatusMove(MoveId.MAGNET_RISE, PokemonType.ELECTRIC, -1, 10, -1, 0, 4)
.attr(AddBattlerTagAttr, BattlerTagType.FLOATING, true, true, 5)
.condition((user, target, move) => !globalScene.arena.getTag(ArenaTagType.GRAVITY) && [ BattlerTagType.FLOATING, BattlerTagType.IGNORE_FLYING, BattlerTagType.INGRAIN ].every((tag) => !user.getTag(tag))),
.condition(failOnGravityCondition)
.condition(failOnGroundedCondition),
new AttackMove(MoveId.FLARE_BLITZ, PokemonType.FIRE, MoveCategory.PHYSICAL, 120, 100, 15, 10, 0, 4)
.attr(RecoilAttr, false, 0.33)
.attr(HealStatusEffectAttr, true, StatusEffect.FREEZE)
@ -9977,12 +10003,12 @@ export function initMoves() {
.powderMove()
.attr(AddBattlerTagAttr, BattlerTagType.CENTER_OF_ATTENTION, true),
new StatusMove(MoveId.TELEKINESIS, PokemonType.PSYCHIC, -1, 15, -1, 0, 5)
.condition(failOnGravityCondition)
.condition((_user, target, _move) => ![ SpeciesId.DIGLETT, SpeciesId.DUGTRIO, SpeciesId.ALOLA_DIGLETT, SpeciesId.ALOLA_DUGTRIO, SpeciesId.SANDYGAST, SpeciesId.PALOSSAND, SpeciesId.WIGLETT, SpeciesId.WUGTRIO ].includes(target.species.speciesId))
.condition((_user, target, _move) => !(target.species.speciesId === SpeciesId.GENGAR && target.getFormKey() === "mega"))
.condition((_user, target, _move) => isNullOrUndefined(target.getTag(BattlerTagType.INGRAIN)) && isNullOrUndefined(target.getTag(BattlerTagType.IGNORE_FLYING)))
.attr(AddBattlerTagAttr, BattlerTagType.TELEKINESIS, false, true, 3)
.attr(AddBattlerTagAttr, BattlerTagType.FLOATING, false, true, 3)
.condition((_user, target, _move) => ![ SpeciesId.DIGLETT, SpeciesId.DUGTRIO, SpeciesId.ALOLA_DIGLETT, SpeciesId.ALOLA_DUGTRIO, SpeciesId.SANDYGAST, SpeciesId.PALOSSAND, SpeciesId.WIGLETT, SpeciesId.WUGTRIO ].includes(target.species.speciesId))
.condition((_user, target, _move) => !(target.species.speciesId === SpeciesId.GENGAR && target.getFormKey() === "mega"))
.condition(failOnGravityCondition)
.condition(failOnGroundedCondition)
.reflectable(),
new StatusMove(MoveId.MAGIC_ROOM, PokemonType.PSYCHIC, -1, 10, -1, 0, 5)
.ignoresProtect()
@ -9990,8 +10016,6 @@ export function initMoves() {
.unimplemented(),
new AttackMove(MoveId.SMACK_DOWN, PokemonType.ROCK, MoveCategory.PHYSICAL, 50, 100, 15, -1, 0, 5)
.attr(FallDownAttr)
.attr(AddBattlerTagAttr, BattlerTagType.INTERRUPTED)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLYING, BattlerTagType.FLOATING, BattlerTagType.TELEKINESIS ])
.attr(HitsTagAttr, BattlerTagType.FLYING)
.makesContact(false),
new AttackMove(MoveId.STORM_THROW, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 60, 100, 10, -1, 0, 5)
@ -10452,8 +10476,6 @@ export function initMoves() {
.attr(FallDownAttr)
.attr(HitsTagAttr, BattlerTagType.FLYING)
.attr(HitsTagAttr, BattlerTagType.FLOATING)
.attr(AddBattlerTagAttr, BattlerTagType.INTERRUPTED)
.attr(RemoveBattlerTagAttr, [ BattlerTagType.FLYING, BattlerTagType.FLOATING, BattlerTagType.TELEKINESIS ])
.makesContact(false)
.target(MoveTarget.ALL_NEAR_ENEMIES),
new AttackMove(MoveId.THOUSAND_WAVES, PokemonType.GROUND, MoveCategory.PHYSICAL, 90, 100, 10, 100, 0, 6)

View File

@ -23,17 +23,21 @@ export class Terrain {
public terrainType: TerrainType;
public turnsLeft: number;
constructor(terrainType: TerrainType, turnsLeft?: number) {
constructor(terrainType: TerrainType, turnsLeft = 0) {
this.terrainType = terrainType;
this.turnsLeft = turnsLeft || 0;
this.turnsLeft = turnsLeft;
}
/**
* Tick down this terrain's duration.
* @returns Whether the current terrain should remain active (`turnsLeft > 0`)
*/
lapse(): boolean {
if (this.turnsLeft) {
return !!--this.turnsLeft;
// TODO: Add separate flag for infinite duration terrains
if (this.turnsLeft <= 0) {
return true;
}
return true;
return --this.turnsLeft > 0;
}
getAttackTypeMultiplier(attackType: PokemonType): number {

View File

@ -20,20 +20,25 @@ export class Weather {
public weatherType: WeatherType;
public turnsLeft: number;
constructor(weatherType: WeatherType, turnsLeft?: number) {
constructor(weatherType: WeatherType, turnsLeft = 0) {
this.weatherType = weatherType;
this.turnsLeft = !this.isImmutable() ? turnsLeft || 0 : 0;
this.turnsLeft = this.isImmutable() ? 0 : turnsLeft;
}
/**
* Tick down this weather's duration.
* @returns Whether the current weather should remain active (`turnsLeft > 0`)
*/
lapse(): boolean {
if (this.isImmutable()) {
return true;
}
if (this.turnsLeft) {
return !!--this.turnsLeft;
if (this.turnsLeft <= 0) {
return true;
}
return true;
return --this.turnsLeft > 0;
}
isImmutable(): boolean {
@ -127,6 +132,7 @@ export class Weather {
}
}
// TODO: These functions should return empty strings instead of `null` - requires bangs
export function getWeatherStartMessage(weatherType: WeatherType): string | null {
switch (weatherType) {
case WeatherType.SUNNY:

View File

@ -289,20 +289,18 @@ export class Arena {
}
/**
* Sets weather to the override specified in overrides.ts
* @param weather new {@linkcode WeatherType} to set
* @returns true to force trySetWeather to return true
* Sets weather to the override specified in overrides.ts`
*/
trySetWeatherOverride(weather: WeatherType): boolean {
private overrideWeather(): void {
const weather = Overrides.WEATHER_OVERRIDE;
this.weather = new Weather(weather, 0);
globalScene.phaseManager.unshiftNew("CommonAnimPhase", undefined, undefined, CommonAnim.SUNNY + (weather - 1));
globalScene.phaseManager.queueMessage(getWeatherStartMessage(weather)!); // TODO: is this bang correct?
return true;
}
/** Returns weather or not the weather can be changed to {@linkcode weather} */
canSetWeather(weather: WeatherType): boolean {
return !(this.weather?.weatherType === (weather || undefined));
return this.getWeatherType() !== weather;
}
/**
@ -313,14 +311,15 @@ export class Arena {
*/
trySetWeather(weather: WeatherType, user?: Pokemon): boolean {
if (Overrides.WEATHER_OVERRIDE) {
return this.trySetWeatherOverride(Overrides.WEATHER_OVERRIDE);
this.overrideWeather();
return true;
}
if (!this.canSetWeather(weather)) {
return false;
}
const oldWeatherType = this.weather?.weatherType || WeatherType.NONE;
const oldWeatherType = this.getWeatherType();
if (
this.weather?.isImmutable() &&
@ -344,7 +343,7 @@ export class Arena {
globalScene.applyModifier(FieldEffectModifier, user.isPlayer(), user, weatherDuration);
}
this.weather = weather ? new Weather(weather, weatherDuration.value) : null;
this.weather = weather === WeatherType.NONE ? null : new Weather(weather, weatherDuration.value);
this.eventTarget.dispatchEvent(
new WeatherChangedEvent(oldWeatherType, this.weather?.weatherType!, this.weather?.turnsLeft!),
); // TODO: is this bang correct?
@ -405,25 +404,24 @@ export class Arena {
});
}
/** Returns whether or not the terrain can be set to {@linkcode terrain} */
/** Return whether or not the terrain can be set to {@linkcode terrain} */
canSetTerrain(terrain: TerrainType): boolean {
return !(this.terrain?.terrainType === (terrain || undefined));
return this.getTerrainType() !== terrain;
}
/**
* Attempts to set a new terrain effect to the battle
* @param terrain {@linkcode TerrainType} new {@linkcode TerrainType} to set
* @param ignoreAnim boolean if the terrain animation should be ignored
* @param user {@linkcode Pokemon} that caused the terrain effect
* @returns true if new terrain set, false if no terrain provided or attempting to set the same terrain as currently in use
* Attempt to set the current terrain to the specified type.
* @param terrain - The {@linkcode TerrainType} to try and set.
* @param ignoreAnim - Whether to prevent showing an the animation; default `false`
* @param user - The {@linkcode Pokemon} creating the terrain (if any)
* @returns Whether the terrain was successfully set.
*/
trySetTerrain(terrain: TerrainType, ignoreAnim = false, user?: Pokemon): boolean {
if (!this.canSetTerrain(terrain)) {
return false;
}
const oldTerrainType = this.terrain?.terrainType || TerrainType.NONE;
const oldTerrainType = this.getTerrainType();
const terrainDuration = new NumberHolder(0);
if (!isNullOrUndefined(user)) {
@ -431,7 +429,7 @@ export class Arena {
globalScene.applyModifier(FieldEffectModifier, user.isPlayer(), user, terrainDuration);
}
this.terrain = terrain ? new Terrain(terrain, terrainDuration.value) : null;
this.terrain = terrain === TerrainType.NONE ? null : new Terrain(terrain, terrainDuration.value);
this.eventTarget.dispatchEvent(
new TerrainChangedEvent(oldTerrainType, this.terrain?.terrainType!, this.terrain?.turnsLeft!),
@ -465,6 +463,24 @@ export class Arena {
return true;
}
/** Attempt to override the terrain to the value set inside {@linkcode Overrides.STARTING_TERRAIN_OVERRIDE}. */
tryOverrideTerrain(): void {
const terrain = Overrides.STARTING_TERRAIN_OVERRIDE;
if (terrain === TerrainType.NONE) {
return;
}
// TODO: Add a flag for permanent terrains
this.terrain = new Terrain(terrain, 0);
globalScene.phaseManager.unshiftNew(
"CommonAnimPhase",
undefined,
undefined,
CommonAnim.MISTY_TERRAIN + (terrain - 1),
);
globalScene.phaseManager.queueMessage(getTerrainStartMessage(terrain) ?? ""); // TODO: Remove `?? ""` when terrain-fail-msg branch removes `null` from these signatures
}
public isMoveWeatherCancelled(user: Pokemon, move: Move): boolean {
return !!this.weather && !this.weather.isEffectSuppressed() && this.weather.isMoveWeatherCancelled(user, move);
}

View File

@ -2279,13 +2279,29 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
return this.teraType;
}
public isGrounded(): boolean {
/**
* Return whether this Pokemon is currently on the ground.
*
* To be considered grounded, a Pokemon must either:
* * Be {@linkcode GroundedTag | forcibly grounded} from an effect like Smack Down or Ingrain
* * Be under the effects of {@linkcode ArenaTagType.GRAVITY | harsh gravity}
* * **Not** be all of the following things:
* * {@linkcode PokemonType.FLYING | Flying-type}
* * {@linkcode AbilityId.LEVITATE | Levitating}
* * {@linkcode BattlerTagType.FLOATING | Floating} from Magnet Rise or Telekinesis.
* * {@linkcode SemiInvulnerableTag | Semi-invulnerable} with `ignoreSemiInvulnerable` set to `false`
* @param ignoreSemiInvulnerable - Whether to ignore the target's semi-invulnerable state when determining groundedness;
default `false`
* @returns Whether this pokemon is currently grounded, as described above.
*/
public isGrounded(ignoreSemiInvulnerable = false): boolean {
return (
!!this.getTag(GroundedTag) ||
globalScene.arena.hasTag(ArenaTagType.GRAVITY) ||
(!this.isOfType(PokemonType.FLYING, true, true) &&
!this.hasAbility(AbilityId.LEVITATE) &&
!this.getTag(BattlerTagType.FLOATING) &&
!this.getTag(SemiInvulnerableTag))
(ignoreSemiInvulnerable || !this.getTag(SemiInvulnerableTag)))
);
}
@ -2485,7 +2501,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
// Handle flying v ground type immunity without removing flying type so effective types are still effective
// Related to https://github.com/pagefaultgames/pokerogue/issues/524
if (moveType === PokemonType.GROUND && (this.isGrounded() || arena.hasTag(ArenaTagType.GRAVITY))) {
if (moveType === PokemonType.GROUND && this.isGrounded()) {
const flyingIndex = types.indexOf(PokemonType.FLYING);
if (flyingIndex > -1) {
types.splice(flyingIndex, 1);
@ -3752,6 +3768,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
const isPhysical = moveCategory === MoveCategory.PHYSICAL;
/** Combined damage multiplier from field effects such as weather, terrain, etc. */
// TODO: This should be applied directly to base power
const arenaAttackTypeMultiplier = new NumberHolder(
globalScene.arena.getAttackTypeMultiplier(moveType, source.isGrounded()),
);

View File

@ -1,4 +1,5 @@
import { type PokeballCounts } from "#app/battle-scene";
import { TerrainType } from "#app/data/terrain";
import { EvolutionItem } from "#balance/pokemon-evolutions";
import { Gender } from "#data/gender";
import { AbilityId } from "#enums/ability-id";
@ -61,6 +62,12 @@ class DefaultOverrides {
readonly SEED_OVERRIDE: string = "";
readonly DAILY_RUN_SEED_OVERRIDE: string | null = null;
readonly WEATHER_OVERRIDE: WeatherType = WeatherType.NONE;
/**
* If set, will override the in-game terrain at the start of each biome transition.
*
* Lasts until cleared or replaced by another effect, and is refreshed at the start of each new biome.
*/
readonly STARTING_TERRAIN_OVERRIDE: TerrainType = TerrainType.NONE;
/**
* If `null`, ignore this override.
*

View File

@ -690,6 +690,7 @@ export class EncounterPhase extends BattlePhase {
trySetWeatherIfNewBiome(): void {
if (!this.loaded) {
globalScene.arena.trySetWeather(getRandomWeatherType(globalScene.arena));
globalScene.arena.tryOverrideTerrain();
}
}
}

View File

@ -12,7 +12,7 @@ import { BooleanHolder, toDmgValue } from "#utils/common";
export class WeatherEffectPhase extends CommonAnimPhase {
public readonly phaseName = "WeatherEffectPhase";
public weather: Weather | null;
public weather: Weather | null; // TODO: This should not be `null`
constructor() {
super(

View File

@ -1,8 +1,6 @@
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { ArenaTagType } from "#enums/arena-tag-type";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
@ -27,130 +25,49 @@ describe("Arena - Gravity", () => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.moveset([MoveId.TACKLE, MoveId.GRAVITY, MoveId.FISSURE])
.ability(AbilityId.UNNERVE)
.enemyAbility(AbilityId.BALL_FETCH)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyMoveset(MoveId.SPLASH)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyLevel(5);
});
// Reference: https://bulbapedia.bulbagarden.net/wiki/Gravity_(move)
it("non-OHKO move accuracy is multiplied by 1.67", async () => {
const moveToCheck = allMoves[MoveId.TACKLE];
vi.spyOn(moveToCheck, "calculateBattleAccuracy");
// Setup Gravity on first turn
it("should multiply all non-OHKO move accuracy by 1.67x", async () => {
const accSpy = vi.spyOn(allMoves[MoveId.TACKLE], "calculateBattleAccuracy");
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.use(MoveId.GRAVITY);
await game.move.forceEnemyMove(MoveId.TACKLE);
await game.toEndOfTurn();
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use non-OHKO move on second turn
await game.toNextTurn();
game.move.select(MoveId.TACKLE);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(moveToCheck.calculateBattleAccuracy).toHaveLastReturnedWith(100 * 1.67);
expect(accSpy).toHaveLastReturnedWith(allMoves[MoveId.TACKLE].accuracy * 1.67);
});
it("OHKO move accuracy is not affected", async () => {
/** See Fissure {@link https://bulbapedia.bulbagarden.net/wiki/Fissure_(move)} */
const moveToCheck = allMoves[MoveId.FISSURE];
vi.spyOn(moveToCheck, "calculateBattleAccuracy");
// Setup Gravity on first turn
it("should not affect OHKO move accuracy", async () => {
const accSpy = vi.spyOn(allMoves[MoveId.FISSURE], "calculateBattleAccuracy");
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase");
game.move.use(MoveId.GRAVITY);
await game.move.forceEnemyMove(MoveId.FISSURE);
await game.toEndOfTurn();
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use OHKO move on second turn
await game.toNextTurn();
game.move.select(MoveId.FISSURE);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(moveToCheck.calculateBattleAccuracy).toHaveLastReturnedWith(30);
expect(accSpy).toHaveLastReturnedWith(allMoves[MoveId.FISSURE].accuracy);
});
describe("Against flying types", () => {
it("can be hit by ground-type moves now", async () => {
game.override.enemySpecies(SpeciesId.PIDGEOT).moveset([MoveId.GRAVITY, MoveId.EARTHQUAKE]);
it("should forcibly ground all Pokemon for the duration of the effect", async () => {
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
const pidgeot = game.field.getEnemyPokemon();
vi.spyOn(pidgeot, "getAttackTypeEffectiveness");
// Try earthquake on 1st turn (fails!);
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(0);
// Setup Gravity on 2nd turn
await game.toNextTurn();
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use ground move on 3rd turn
await game.toNextTurn();
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(1);
});
it("keeps super-effective moves super-effective after using gravity", async () => {
game.override.enemySpecies(SpeciesId.PIDGEOT).moveset([MoveId.GRAVITY, MoveId.THUNDERBOLT]);
await game.classicMode.startBattle([SpeciesId.PIKACHU]);
const pidgeot = game.field.getEnemyPokemon();
vi.spyOn(pidgeot, "getAttackTypeEffectiveness");
// Setup Gravity on 1st turn
game.move.select(MoveId.GRAVITY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
// Use electric move on 2nd turn
await game.toNextTurn();
game.move.select(MoveId.THUNDERBOLT);
await game.phaseInterceptor.to("TurnEndPhase");
expect(pidgeot.getAttackTypeEffectiveness).toHaveLastReturnedWith(2);
});
});
it("cancels Fly if its user is semi-invulnerable", async () => {
game.override.enemySpecies(SpeciesId.SNORLAX).enemyMoveset(MoveId.FLY).moveset([MoveId.GRAVITY, MoveId.SPLASH]);
await game.classicMode.startBattle([SpeciesId.CHARIZARD]);
const charizard = game.field.getPlayerPokemon();
const snorlax = game.field.getEnemyPokemon();
game.move.select(MoveId.SPLASH);
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.GRAVITY);
await game.toNextTurn();
expect(snorlax.getTag(BattlerTagType.FLYING)).toBeDefined();
game.move.select(MoveId.GRAVITY);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(snorlax.getTag(BattlerTagType.INTERRUPTED)).toBeDefined();
await game.phaseInterceptor.to("TurnEndPhase");
expect(charizard.hp).toBe(charizard.getMaxHp());
expect(game.scene.arena.getTag(ArenaTagType.GRAVITY)).toBeDefined();
expect(player.isGrounded()).toBe(true);
expect(enemy.isGrounded()).toBe(true);
});
});

View File

@ -1,69 +0,0 @@
import { allMoves } from "#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/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Arena - Grassy Terrain", () => {
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")
.criticalHits(false)
.enemyLevel(1)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.FLY)
.moveset([MoveId.GRASSY_TERRAIN, MoveId.EARTHQUAKE])
.ability(AbilityId.NO_GUARD);
});
it("halves the damage of Earthquake", async () => {
await game.classicMode.startBattle([SpeciesId.TAUROS]);
const eq = allMoves[MoveId.EARTHQUAKE];
vi.spyOn(eq, "calculateBattlePower");
game.move.select(MoveId.EARTHQUAKE);
await game.toNextTurn();
expect(eq.calculateBattlePower).toHaveReturnedWith(100);
game.move.select(MoveId.GRASSY_TERRAIN);
await game.toNextTurn();
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("BerryPhase");
expect(eq.calculateBattlePower).toHaveReturnedWith(50);
});
it("Does not halve the damage of Earthquake if opponent is not grounded", async () => {
await game.classicMode.startBattle([SpeciesId.NINJASK]);
const eq = allMoves[MoveId.EARTHQUAKE];
vi.spyOn(eq, "calculateBattlePower");
game.move.select(MoveId.GRASSY_TERRAIN);
await game.toNextTurn();
game.move.select(MoveId.EARTHQUAKE);
await game.phaseInterceptor.to("BerryPhase");
expect(eq.calculateBattlePower).toHaveReturnedWith(100);
});
});

View File

@ -1,59 +0,0 @@
import { AbilityId } from "#enums/ability-id";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { WeatherType } from "#enums/weather-type";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Arena - Psychic Terrain", () => {
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")
.criticalHits(false)
.enemyLevel(1)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.SPLASH)
.moveset([MoveId.PSYCHIC_TERRAIN, MoveId.RAIN_DANCE, MoveId.DARK_VOID])
.ability(AbilityId.NO_GUARD);
});
it("Dark Void with Prankster is not blocked", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.PSYCHIC_TERRAIN);
await game.toNextTurn();
game.move.select(MoveId.DARK_VOID);
await game.toEndOfTurn();
expect(game.field.getEnemyPokemon().status?.effect).toBe(StatusEffect.SLEEP);
});
it("Rain Dance with Prankster is not blocked", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.select(MoveId.PSYCHIC_TERRAIN);
await game.toNextTurn();
game.move.select(MoveId.RAIN_DANCE);
await game.toEndOfTurn();
expect(game.scene.arena.weather?.weatherType).toBe(WeatherType.RAIN);
});
});

400
test/arena/terrain.test.ts Normal file
View File

@ -0,0 +1,400 @@
import { allMoves } from "#app/data/data-lists";
import { getTerrainName, TerrainType } from "#app/data/terrain";
import { getPokemonNameWithAffix } from "#app/messages";
import { randSeedInt } from "#app/utils/common";
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { WeatherType } from "#enums/weather-type";
import { GameManager } from "#test/test-utils/game-manager";
import { toTitleCase } from "#utils/strings";
import i18next from "i18next";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Terrain -", () => {
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")
.criticalHits(false)
.startingLevel(100)
.enemyLevel(100)
.enemySpecies(SpeciesId.SHUCKLE)
.enemyAbility(AbilityId.STURDY)
.passiveAbility(AbilityId.NO_GUARD);
});
// TODO: Terrain boosts currently apply to damage dealt, not base power
describe.todo.each<{ name: string; type: PokemonType; terrain: TerrainType; move: MoveId }>([
{ name: "Electric", type: PokemonType.ELECTRIC, terrain: TerrainType.ELECTRIC, move: MoveId.THUNDERBOLT },
{ name: "Psychic", type: PokemonType.PSYCHIC, terrain: TerrainType.PSYCHIC, move: MoveId.PSYCHIC },
{ name: "Grassy", type: PokemonType.GRASS, terrain: TerrainType.GRASSY, move: MoveId.ENERGY_BALL },
{ name: "Misty", type: PokemonType.FAIRY, terrain: TerrainType.MISTY, move: MoveId.DRAGON_BREATH },
])("Common Tests - $name Terrain", ({ type, terrain, move }) => {
// biome-ignore lint/suspicious/noDuplicateTestHooks: This is a TODO test case
beforeEach(() => {
game.override.terrain(terrain).enemyPassiveAbility(AbilityId.LEVITATE);
});
const typeStr = toTitleCase(PokemonType[type]);
it.skipIf(terrain === TerrainType.MISTY)(
`should boost power of grounded ${typeStr}-type moves by 1.3x, even against ungrounded targets`,
async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower");
game.move.use(move);
await game.move.forceEnemyMove(move);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// Player grounded attack got boosted while enemy ungrounded attack didn't
expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power * 1.3);
expect(powerSpy).toHaveNthReturnedWith(1, allMoves[move].power);
},
);
it.runIf(terrain === TerrainType.MISTY)(
"should cut power of grounded Dragon-type moves in half, even from ungrounded users",
async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower");
game.move.use(move);
await game.move.forceEnemyMove(move);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// Enemy dragon breath got nerfed against grounded player; player dragon breath did not
expect(powerSpy).toHaveLastReturnedWith(allMoves[move].power);
expect(powerSpy).toHaveNthReturnedWith(1, allMoves[move].power * 0.5);
},
);
// TODO: Move to a dedicated terrain pulse test file
it(`should change Terrain Pulse into a ${typeStr}-type move and double its base power`, async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const powerSpy = vi.spyOn(allMoves[MoveId.TERRAIN_PULSE], "calculateBattlePower");
const playerTypeSpy = vi.spyOn(game.field.getPlayerPokemon(), "getMoveType");
const enemyTypeSpy = vi.spyOn(game.field.getEnemyPokemon(), "getMoveType");
game.move.use(MoveId.TERRAIN_PULSE);
await game.move.forceEnemyMove(MoveId.TERRAIN_PULSE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// player grounded terrain pulse was boosted & type converted; enemy ungrounded one wasn't
expect(powerSpy).toHaveLastReturnedWith(
allMoves[MoveId.TERRAIN_PULSE].power * (terrain === TerrainType.MISTY ? 2 : 2.6),
); // 2 * 1.3
expect(playerTypeSpy).toHaveLastReturnedWith(type);
expect(powerSpy).toHaveNthReturnedWith(1, allMoves[MoveId.TERRAIN_PULSE].power);
expect(enemyTypeSpy).toHaveNthReturnedWith(1, allMoves[MoveId.TERRAIN_PULSE].type);
});
});
describe("Grassy Terrain", () => {
beforeEach(() => {
game.override.terrain(TerrainType.GRASSY);
});
it("should heal all grounded, non semi-invulnerable Pokemon for 1/16th max HP at end of turn", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
// blissey is grounded, shuckle isn't
const blissey = game.field.getPlayerPokemon();
blissey.hp /= 2;
const shuckle = game.field.getEnemyPokemon();
game.field.mockAbility(shuckle, AbilityId.LEVITATE);
shuckle.hp /= 2;
game.move.use(MoveId.SPLASH);
await game.toNextTurn();
expect(game.phaseInterceptor.log).toContain("PokemonHealPhase");
expect(blissey.getHpRatio()).toBeCloseTo(0.5625, 1);
expect(shuckle.getHpRatio()).toBeCloseTo(0.5, 1);
game.move.use(MoveId.DIG);
await game.toNextTurn();
// shuckle is airborne and blissey is semi-invulnerable, so nobody gets healed
expect(blissey.getHpRatio()).toBeCloseTo(0.5625, 1);
expect(shuckle.getHpRatio()).toBeCloseTo(0.5, 1);
});
// TODO: Enable once magnitude to return a specific power rating
it.todo.each<{ name: string; move: MoveId; basePower?: number }>([
{ name: "Bulldoze", move: MoveId.BULLDOZE },
{ name: "Earthquake", move: MoveId.EARTHQUAKE },
{ name: "Magnitude", move: MoveId.MAGNITUDE, basePower: 150 }, // magnitude 10
])(
"should halve $name's base power against grounded, on-field targets",
async ({ move, basePower = allMoves[move].power }) => {
await game.classicMode.startBattle([SpeciesId.TAUROS]);
// force high rolls for guaranteed magnitude 10s
vi.fn(randSeedInt).mockReturnValue(100);
const powerSpy = vi.spyOn(allMoves[move], "calculateBattlePower");
const enemy = game.field.getEnemyPokemon();
// Turn 1: attack with grassy terrain active; 0.5x
game.move.use(move);
await game.toNextTurn();
expect(powerSpy).toHaveLastReturnedWith(basePower / 2);
// Turn 2: Give enemy levitate to make ungrounded and attack; 1x
// (hits due to no guard)
game.field.mockAbility(game.field.getEnemyPokemon(), AbilityId.LEVITATE);
game.move.use(move);
await game.toNextTurn();
expect(powerSpy).toHaveLastReturnedWith(basePower);
// Turn 3: Remove levitate and make enemy semi-invulnerable; 1x
game.field.mockAbility(game.field.getEnemyPokemon(), AbilityId.BALL_FETCH);
game.move.use(move);
await game.move.forceEnemyMove(MoveId.FLY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn();
expect(enemy.getLastXMoves()[0].move).toBe(MoveId.FLY);
expect(powerSpy).toHaveLastReturnedWith(basePower);
},
);
});
describe("Electric Terrain", () => {
beforeEach(() => {
game.override.terrain(TerrainType.ELECTRIC);
});
it("should prevent all grounded Pokemon from being put to sleep", async () => {
await game.classicMode.startBattle([SpeciesId.PIDGEOT]);
game.move.use(MoveId.SPORE);
await game.move.forceEnemyMove(MoveId.SPORE);
await game.toEndOfTurn();
const pidgeot = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(pidgeot.status?.effect).toBe(StatusEffect.SLEEP);
expect(shuckle.status?.effect).toBeUndefined();
// TODO: These don't work due to how move failures are propagated
// expect(pidgeot.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
// expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.ELECTRIC),
}),
);
});
it("should prevent attack moves from applying sleep without showing text/failing move", async () => {
vi.spyOn(allMoves[MoveId.RELIC_SONG], "chance", "get").mockReturnValue(100);
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
const shuckle = game.field.getEnemyPokemon();
const statusSpy = vi.spyOn(shuckle, "canSetStatus");
game.move.use(MoveId.RELIC_SONG);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
expect(shuckle.status?.effect).toBeUndefined();
expect(statusSpy).toHaveLastReturnedWith(false);
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).not.toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.ELECTRIC),
}),
);
});
});
describe("Misty Terrain", () => {
beforeEach(() => {
game.override.terrain(TerrainType.MISTY).enemyPassiveAbility(AbilityId.LEVITATE);
});
it("should prevent all grounded Pokemon from gaining non-volatile statuses", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.TOXIC);
await game.move.forceEnemyMove(MoveId.TOXIC);
await game.toNextTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
// blissey is grounded & protected, shuckle isn't
expect(blissey.status?.effect).toBeUndefined();
expect(shuckle.status?.effect).toBe(StatusEffect.TOXIC);
// TODO: These don't work due to how move failures are propagated
// expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
// expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:mistyBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(blissey),
}),
);
});
it("should block confusion and display message", async () => {
game.override.confusionActivation(false); // prevent self hits from cancelling move
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.CONFUSE_RAY);
await game.move.forceEnemyMove(MoveId.CONFUSE_RAY);
await game.toNextTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
// blissey is grounded & protected, shuckle isn't
expect(blissey).not.toHaveBattlerTag(BattlerTagType.CONFUSED);
expect(shuckle).toHaveBattlerTag(BattlerTagType.CONFUSED);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:mistyBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(blissey),
}),
);
});
it.each<{ status: string; move: MoveId }>([
{ status: "Sleep", move: MoveId.RELIC_SONG },
{ status: "Burn", move: MoveId.SACRED_FIRE },
{ status: "Freeze", move: MoveId.ICE_BEAM },
{ status: "Paralysis", move: MoveId.NUZZLE },
{ status: "Poison", move: MoveId.SLUDGE_BOMB },
{ status: "Toxic Poison", move: MoveId.MALIGNANT_CHAIN },
// TODO: Confusion currently displays terrain block message even from damaging moves
// { status: "Confusion", move: MoveId.MAGICAL_TORQUE },
])("should prevent attack moves from applying $status without showing text/failing move", async ({ move }) => {
vi.spyOn(allMoves[move], "chance", "get").mockReturnValue(100);
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(move);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
// Blissey was grounded and protected from effect, but still took damage
expect(blissey).not.toHaveFullHp();
expect(blissey).not.toHaveBattlerTag(BattlerTagType.CONFUSED);
expect(blissey.status?.effect).toBe(StatusEffect.NONE);
expect(shuckle).toHaveUsedMove({ result: MoveResult.SUCCESS });
expect(game.textInterceptor.logs).not.toContain(
i18next.t("terrain:mistyBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(blissey),
}),
);
});
});
describe("Psychic Terrain", () => {
beforeEach(() => {
game.override.terrain(TerrainType.PSYCHIC).ability(AbilityId.GALE_WINGS).enemyAbility(AbilityId.PRANKSTER);
});
it("should block all opponent-targeted priority moves", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.FAKE_OUT);
await game.move.forceEnemyMove(MoveId.FOLLOW_ME);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.PSYCHIC),
}),
);
});
it("should affect moves that only become priority due to abilities", async () => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(MoveId.FEATHER_DANCE);
await game.move.forceEnemyMove(MoveId.SWORDS_DANCE);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
const shuckle = game.field.getEnemyPokemon();
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
expect(shuckle.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
expect(game.textInterceptor.logs).toContain(
i18next.t("terrain:defaultBlockMessage", {
pokemonNameWithAffix: getPokemonNameWithAffix(shuckle),
terrainName: getTerrainName(TerrainType.PSYCHIC),
}),
);
});
it.each<{ category: string; move: MoveId; effect: () => void }>([
{
category: "Field-targeted",
move: MoveId.RAIN_DANCE,
effect: () => {
expect(game.scene.arena.getWeatherType()).toBe(WeatherType.RAIN);
},
},
{
category: "Enemy-targeting spread",
move: MoveId.DARK_VOID,
effect: () => {
expect(game.field.getEnemyPokemon().status?.effect).toBe(StatusEffect.SLEEP);
},
},
])("should not block $category moves that become priority", async ({ move, effect }) => {
await game.classicMode.startBattle([SpeciesId.BLISSEY]);
game.move.use(move);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toEndOfTurn();
const blissey = game.field.getPlayerPokemon();
expect(blissey.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
effect();
});
});
});

View File

@ -0,0 +1,131 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Fly and Bounce", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.battleStyle("single")
.ability(AbilityId.COMPOUND_EYES)
.enemySpecies(SpeciesId.SNORLAX)
.startingLevel(100)
.enemyLevel(100)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.TACKLE);
});
// TODO: Move to a global "charging moves" test file
it.each([
{ name: "Fly", move: MoveId.FLY },
{ name: "Bounce", move: MoveId.BOUNCE },
])("should make the user semi-invulnerable, then attack over 2 turns", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
game.move.use(MoveId.FLY);
await game.toEndOfTurn();
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
expect(player.getTag(BattlerTagType.FLYING)).toBeDefined();
expect(enemy.getLastXMoves(1)[0].result).toBe(MoveResult.MISS);
expect(player.hp).toBe(player.getMaxHp());
expect(enemy.hp).toBe(enemy.getMaxHp());
expect(player.getMoveQueue()[0].move).toBe(MoveId.FLY);
await game.toEndOfTurn();
expect(player.getTag(BattlerTagType.FLYING)).toBeUndefined();
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
expect(player.getMoveHistory()).toHaveLength(2);
const playerFly = player.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(1);
});
it("should not allow the user to evade attacks from Pokemon with No Guard", async () => {
game.override.enemyAbility(AbilityId.NO_GUARD);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.FLY);
await game.toEndOfTurn();
expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp());
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
});
it("should not expend PP when the attack phase is cancelled", async () => {
game.override.enemyAbility(AbilityId.NO_GUARD).enemyMoveset(MoveId.SPORE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
game.move.select(MoveId.FLY);
await game.toEndOfTurn();
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined();
expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP);
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(0);
});
// TODO: We currently cancel Fly/Bounce in a really scuffed way
it.todo.each<{ name: string; move: MoveId }>([
{ name: "Smack Down", move: MoveId.SMACK_DOWN },
{ name: "Thousand Arrows", move: MoveId.THOUSAND_ARROWS },
{ name: "Gravity", move: MoveId.GRAVITY },
])("should be cancelled immediately when $name is used", async ({ move }) => {
await game.classicMode.startBattle([SpeciesId.AZURILL]);
game.move.use(MoveId.BOUNCE);
await game.move.forceEnemyMove(move);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEndPhase");
// Bounce should've worked until hit
const azurill = game.field.getPlayerPokemon();
expect(azurill.getTag(BattlerTagType.FLYING)).toBeDefined();
expect(azurill.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
await game.phaseInterceptor.to("MoveEndPhase");
expect(azurill.getTag(BattlerTagType.FLYING)).toBeUndefined();
expect(azurill.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(azurill.getMoveQueue()).toHaveLength(0);
expect(azurill.visible).toBe(true);
if (move !== MoveId.GRAVITY) {
expect(azurill.hp).toBeLessThan(azurill.getMaxHp());
}
await game.toEndOfTurn();
const snorlax = game.field.getEnemyPokemon();
expect(snorlax.hp).toBe(snorlax.getMaxHp());
});
});

View File

@ -1,120 +0,0 @@
import { allMoves } from "#data/data-lists";
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Fly", () => {
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.FLY)
.battleStyle("single")
.startingLevel(100)
.enemySpecies(SpeciesId.SNORLAX)
.enemyLevel(100)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset(MoveId.TACKLE);
vi.spyOn(allMoves[MoveId.FLY], "accuracy", "get").mockReturnValue(100);
});
it("should make the user semi-invulnerable, then attack over 2 turns", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeDefined();
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.MISS);
expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp());
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveQueue()[0].move).toBe(MoveId.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
expect(playerPokemon.getMoveHistory()).toHaveLength(2);
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(1);
});
it("should not allow the user to evade attacks from Pokemon with No Guard", async () => {
game.override.enemyAbility(AbilityId.NO_GUARD);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp());
expect(enemyPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.SUCCESS);
});
it("should not expend PP when the attack phase is cancelled", async () => {
game.override.enemyAbility(AbilityId.NO_GUARD).enemyMoveset(MoveId.SPORE);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
game.move.select(MoveId.FLY);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined();
expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP);
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(0);
});
it("should be cancelled when another Pokemon uses Gravity", async () => {
game.override.enemyMoveset([MoveId.SPLASH, MoveId.GRAVITY]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.FLY);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
await game.move.selectEnemyMove(MoveId.GRAVITY);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("TurnEndPhase");
expect(playerPokemon.getLastXMoves(1)[0].result).toBe(MoveResult.FAIL);
expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp());
const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY);
expect(playerFly?.ppUsed).toBe(0);
});
});

View File

@ -1,3 +1,4 @@
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
@ -28,30 +29,16 @@ describe("Moves - Magnet Rise", () => {
.enemyLevel(1);
});
it("should make the user immune to ground-type moves", async () => {
it("should make the user ungrounded when used", async () => {
await game.classicMode.startBattle([SpeciesId.MAGNEZONE]);
game.move.use(MoveId.MAGNET_RISE);
await game.toEndOfTurn();
// magnezone levitated and was not hit by earthquake
const magnezone = game.field.getPlayerPokemon();
expect(magnezone.hp).toBe(magnezone.getMaxHp());
expect(magnezone.getTag(BattlerTagType.FLOATING)).toBeDefined();
expect(magnezone.isGrounded()).toBe(false);
});
it("should be removed by gravity", async () => {
await game.classicMode.startBattle([SpeciesId.MAGNEZONE]);
game.move.use(MoveId.MAGNET_RISE);
await game.toNextTurn();
const magnezone = game.field.getPlayerPokemon();
expect(magnezone.hp).toBe(magnezone.getMaxHp());
game.move.use(MoveId.GRAVITY);
await game.toEndOfTurn();
expect(magnezone.hp).toBeLessThan(magnezone.getMaxHp());
expect(magnezone.isGrounded()).toBe(true);
});
});

View File

@ -0,0 +1,137 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import type { MoveEffectPhase } from "#phases/move-effect-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Moves - Smack Down and Thousand Arrows", () => {
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")
.enemySpecies(SpeciesId.EELEKTROSS)
.startingLevel(100)
.enemyLevel(50)
.criticalHits(false)
.ability(AbilityId.COMPOUND_EYES)
.enemyAbility(AbilityId.STURDY)
.enemyMoveset(MoveId.SPLASH);
});
it.each([
{ name: "Smack Down", move: MoveId.SMACK_DOWN },
{ name: "Thousand Arrows", move: MoveId.THOUSAND_ARROWS },
])("$name should hit and ground ungrounded targets", async ({ move }) => {
game.override.enemySpecies(SpeciesId.TORNADUS);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemy = game.field.getEnemyPokemon();
expect(enemy.isGrounded()).toBe(false);
game.move.use(move);
await game.phaseInterceptor.to("MoveEffectPhase", false);
await game.toEndOfTurn();
expect(enemy.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemy.isGrounded()).toBe(true);
});
it("should affect targets with Levitate", async () => {
game.override.enemyPassiveAbility(AbilityId.LEVITATE);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const eelektross = game.field.getEnemyPokemon();
expect(eelektross.isGrounded()).toBe(false);
game.move.use(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to("MoveEffectPhase", false);
await game.toEndOfTurn();
expect(eelektross.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(eelektross.hp).toBeLessThan(eelektross.getMaxHp());
expect(eelektross.isGrounded()).toBe(true);
});
it.each([
{ name: "Telekinesis", move: MoveId.TELEKINESIS, tags: [BattlerTagType.TELEKINESIS, BattlerTagType.FLOATING] },
{ name: "Magnet Rise", move: MoveId.MAGNET_RISE, tags: [BattlerTagType.FLOATING] },
])("should cancel the ungrounding effects of $name", async ({ move, tags }) => {
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
game.move.use(MoveId.SMACK_DOWN);
await game.move.forceEnemyMove(move);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MoveEndPhase");
// ensure move suceeeded before getting knocked down
const eelektross = game.field.getEnemyPokemon();
tags.forEach(t => {
expect(eelektross.getTag(t)).toBeDefined();
});
expect(eelektross.isGrounded()).toBe(false);
await game.toEndOfTurn();
tags.forEach(t => {
expect(eelektross.getTag(t)).toBeUndefined();
});
expect(eelektross.hp).toBeLessThan(eelektross.getMaxHp());
expect(eelektross.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(eelektross.isGrounded()).toBe(false);
});
// NB: This test might sound useless, but semi-invulnerable pokemon are technically considered "ungrounded"
// by most things
it("should not ground semi-invulnerable targets unless already ungrounded", async () => {
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
game.move.use(MoveId.THOUSAND_ARROWS);
await game.move.forceEnemyMove(MoveId.DIG);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// Eelektross took damage but was not forcibly grounded
const eelektross = game.field.getEnemyPokemon();
expect(eelektross.isGrounded()).toBe(true);
expect(eelektross.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
expect(eelektross.hp).toBeLessThan(eelektross.getMaxHp());
});
// TODO: Sky drop is currently partially implemented
it.todo("should hit midair targets from Sky Drop without interrupting");
describe("Thousand Arrows", () => {
it("should deal a fixed 1x damage to ungrounded flying-types", async () => {
game.override.enemySpecies(SpeciesId.ARCHEOPS);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const archeops = game.field.getEnemyPokemon();
game.move.use(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to("MoveEffectPhase", false);
const hitSpy = vi.spyOn(game.scene.phaseManager.getCurrentPhase() as MoveEffectPhase, "hitCheck");
await game.toEndOfTurn();
expect(hitSpy).toHaveReturnedWith([expect.anything(), 1]);
expect(archeops.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(archeops.isGrounded()).toBe(true);
expect(archeops.hp).toBeLessThan(archeops.getMaxHp());
});
});
});

View File

@ -1,10 +1,13 @@
import { allMoves } from "#data/data-lists";
import { TerrainType } from "#app/data/terrain";
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { BattlerTagType } from "#enums/battler-tag-type";
import { HitCheckResult } from "#enums/hit-check-result";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { SpeciesId } from "#enums/species-id";
import { StatusEffect } from "#enums/status-effect";
import type { MoveEffectPhase } from "#phases/move-effect-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@ -26,114 +29,117 @@ describe("Moves - Telekinesis", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([MoveId.TELEKINESIS, MoveId.TACKLE, MoveId.MUD_SHOT, MoveId.SMACK_DOWN])
.battleStyle("single")
.enemySpecies(SpeciesId.SNORLAX)
.enemyLevel(60)
.enemyAbility(AbilityId.BALL_FETCH)
.enemyMoveset([MoveId.SPLASH]);
.enemyMoveset(MoveId.SPLASH);
});
it("Telekinesis makes the affected vulnerable to most attacking moves regardless of accuracy", async () => {
it("should cause opposing non-OHKO moves to always hit the target", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyOpponent = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
const player = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.TELEKINESIS);
await game.toNextTurn();
vi.spyOn(allMoves[MoveId.TACKLE], "accuracy", "get").mockReturnValue(0);
game.move.select(MoveId.TACKLE);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.isFullHp()).toBe(false);
expect(enemy).toHaveBattlerTag(BattlerTagType.TELEKINESIS);
expect(enemy).toHaveBattlerTag(BattlerTagType.FLOATING);
game.move.use(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.move.forceMiss();
await game.toEndOfTurn();
expect(enemy.hp).toBeLessThan(enemy.getMaxHp());
expect(player.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
});
it("Telekinesis makes the affected airborne and immune to most Ground-moves", async () => {
it("should forcibly unground the target", async () => {
game.override.terrain(TerrainType.ELECTRIC);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyOpponent = game.field.getEnemyPokemon();
const enemy = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
game.move.use(MoveId.TELEKINESIS);
await game.toNextTurn();
// Use Earthquake - should be ineffective
game.move.use(MoveId.EARTHQUAKE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("MoveEffectPhase", false);
const hitSpy = vi.spyOn(game.scene.phaseManager.getCurrentPhase() as MoveEffectPhase, "hitCheck");
await game.toNextTurn();
vi.spyOn(allMoves[MoveId.MUD_SHOT], "accuracy", "get").mockReturnValue(100);
game.move.select(MoveId.MUD_SHOT);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.isFullHp()).toBe(true);
expect(enemy.hp).toBe(enemy.getMaxHp());
expect(hitSpy).toHaveLastReturnedWith([HitCheckResult.NO_EFFECT, 0]);
// Use Spore - should succeed due to being ungrounded
game.move.use(MoveId.SPORE);
await game.toEndOfTurn();
expect(enemy.status?.effect).toBe(StatusEffect.SLEEP);
});
it("Telekinesis can still affect Pokemon that have been transformed into invalid Pokemon", async () => {
game.override.enemyMoveset(MoveId.TRANSFORM);
// TODO: Make an it.each testing the invalid species for Telekinesis
it.todo.each([])("should fail if used on $name", () => {});
it("should still affect enemies transformed into invalid Pokemon", async () => {
game.override.enemyAbility(AbilityId.IMPOSTER);
await game.classicMode.startBattle([SpeciesId.DIGLETT]);
const enemyOpponent = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
game.move.use(MoveId.TELEKINESIS);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
expect(enemyOpponent).toHaveBattlerTag(BattlerTagType.TELEKINESIS);
expect(enemyOpponent).toHaveBattlerTag(BattlerTagType.FLOATING);
expect(enemyOpponent.summonData.speciesForm?.speciesId).toBe(SpeciesId.DIGLETT);
});
it("Moves like Smack Down and 1000 Arrows remove all effects of Telekinesis from the target Pokemon", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyOpponent = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
await game.toNextTurn();
game.move.select(MoveId.SMACK_DOWN);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeUndefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeUndefined();
});
it("Ingrain will remove the floating effect of Telekinesis, but not the 100% hit", async () => {
game.override.enemyMoveset([MoveId.SPLASH, MoveId.INGRAIN]);
it("should become grounded when Ingrain is used, but not remove the guaranteed hit effect", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const playerPokemon = game.field.getPlayerPokemon();
const enemyOpponent = game.field.getEnemyPokemon();
game.move.select(MoveId.TELEKINESIS);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.TELEKINESIS);
await game.toNextTurn();
vi.spyOn(allMoves[MoveId.MUD_SHOT], "accuracy", "get").mockReturnValue(0);
game.move.select(MoveId.MUD_SHOT);
await game.move.selectEnemyMove(MoveId.INGRAIN);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.INGRAIN)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeUndefined();
expect(playerPokemon.getLastXMoves()[0].result).toBe(MoveResult.SUCCESS);
game.move.use(MoveId.MUD_SHOT);
await game.move.forceEnemyMove(MoveId.INGRAIN);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("MoveEndPhase");
await game.move.forceMiss();
await game.toEndOfTurn();
expect(enemy).toHaveBattlerTag(BattlerTagType.TELEKINESIS);
expect(enemy).toHaveBattlerTag(BattlerTagType.INGRAIN);
expect(enemy).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING);
expect(enemy).not.toHaveBattlerTag(BattlerTagType.FLOATING);
expect(enemy.isGrounded()).toBe(true);
expect(playerPokemon).toHaveUsedMove({ move: MoveId.MUD_SHOT, result: MoveResult.SUCCESS });
});
it("should not be baton passed onto a mega gengar", async () => {
game.override
.moveset([MoveId.BATON_PASS])
.enemyMoveset([MoveId.TELEKINESIS])
.starterForms({ [SpeciesId.GENGAR]: 1 });
it("should not be baton passable onto a mega gengar", async () => {
game.override.starterForms({ [SpeciesId.GENGAR]: 1 });
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.GENGAR]);
game.move.select(MoveId.BATON_PASS);
game.move.use(MoveId.BATON_PASS);
game.doSelectPartyPokemon(1);
await game.move.forceEnemyMove(MoveId.TELEKINESIS);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
expect(game.field.getPlayerPokemon().getTag(BattlerTagType.TELEKINESIS)).toBeUndefined();
await game.phaseInterceptor.to("MoveEndPhase");
expect(game.field.getPlayerPokemon()).toHaveBattlerTag(BattlerTagType.TELEKINESIS);
await game.toEndOfTurn();
expect(game.field.getPlayerPokemon()).not.toHaveBattlerTag(BattlerTagType.TELEKINESIS);
});
});

View File

@ -1,89 +0,0 @@
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 { BerryPhase } from "#phases/berry-phase";
import { MoveEffectPhase } from "#phases/move-effect-phase";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Thousand Arrows", () => {
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")
.enemySpecies(SpeciesId.TOGETIC)
.startingLevel(100)
.enemyLevel(100)
.moveset([MoveId.THOUSAND_ARROWS])
.enemyMoveset(MoveId.SPLASH);
});
it("move should hit and ground Flying-type targets", async () => {
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to(MoveEffectPhase, false);
// Enemy should not be grounded before move effect is applied
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
});
it("move should hit and ground targets with Levitate", async () => {
game.override.enemySpecies(SpeciesId.SNORLAX).enemyAbility(AbilityId.LEVITATE);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemyPokemon = game.field.getEnemyPokemon();
game.move.select(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to(MoveEffectPhase, false);
// Enemy should not be grounded before move effect is applied
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined();
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
});
it("move should hit and ground targets under the effects of Magnet Rise", async () => {
game.override.enemySpecies(SpeciesId.SNORLAX);
await game.classicMode.startBattle([SpeciesId.ILLUMISE]);
const enemyPokemon = game.field.getEnemyPokemon();
enemyPokemon.addTag(BattlerTagType.FLOATING, undefined, MoveId.MAGNET_RISE);
game.move.select(MoveId.THOUSAND_ARROWS);
await game.phaseInterceptor.to(BerryPhase, false);
expect(enemyPokemon.getTag(BattlerTagType.FLOATING)).toBeUndefined();
expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp());
});
});

View File

@ -1,7 +1,9 @@
/** biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */
import type { NewArenaEvent } from "#events/battle-scene";
/** biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */
import { TerrainType } from "#app/data/terrain";
import type { BattleStyle, RandomTrainerOverride } from "#app/overrides";
import Overrides from "#app/overrides";
import { AbilityId } from "#enums/ability-id";
@ -359,6 +361,19 @@ export class OverridesHelper extends GameManagerHelper {
return this;
}
/**
* Override the starting {@linkcode TerrainType} that will be set on entering a new biome.
* @param type - The {@linkcode TerrainType} to set.
* @returns `this`
* @remarks
* The newly added terrain will be refreshed upon reaching a new biome, and will be overridden as normal if a new terrain is set.
*/
public terrain(type: TerrainType): this {
vi.spyOn(Overrides, "STARTING_TERRAIN_OVERRIDE", "get").mockReturnValue(type);
this.log(`Starting terrain for next biome set to ${TerrainType[type]} (=${type})!`);
return this;
}
/**
* Override the seed
* @param seed - The seed to set

View File

@ -36,6 +36,7 @@ import { NewBiomeEncounterPhase } from "#phases/new-biome-encounter-phase";
import { NextEncounterPhase } from "#phases/next-encounter-phase";
import { PartyExpPhase } from "#phases/party-exp-phase";
import { PartyHealPhase } from "#phases/party-heal-phase";
import { PokemonHealPhase } from "#phases/pokemon-heal-phase";
import { PokemonTransformPhase } from "#phases/pokemon-transform-phase";
import { PositionalTagPhase } from "#phases/positional-tag-phase";
import { PostGameOverPhase } from "#phases/post-game-over-phase";
@ -146,6 +147,7 @@ export class PhaseInterceptor {
[PositionalTagPhase, this.startPhase],
[PokemonTransformPhase, this.startPhase],
[MysteryEncounterPhase, this.startPhase],
[PokemonHealPhase, this.startPhase],
[MysteryEncounterOptionSelectedPhase, this.startPhase],
[MysteryEncounterBattlePhase, this.startPhase],
[MysteryEncounterRewardsPhase, this.startPhase],