Squashed changes and such

This commit is contained in:
Bertie690 2025-07-30 17:56:07 -04:00
parent 17eeceb4f3
commit 6fe57c7e23
22 changed files with 951 additions and 611 deletions

View File

@ -43,7 +43,7 @@ Serializable ArenaTags have strict rules for their fields.
These rules ensure that only the data necessary to reconstruct the tag is serialized, and that the
session loader is able to deserialize saved tags correctly.
If the data is static (i.e. it is always the same for all instances of the class, such as the
If the data is static (i.e. it is always the same for all instances of the class, such as the
type that is weakened by Mud Sport/Water Sport), then it must not be defined as a field, and must
instead be defined as a getter.
A static property is also acceptable, though static properties are less ergonomic with inheritance.
@ -1226,13 +1226,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

@ -704,6 +704,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) {
@ -733,7 +737,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;
@ -742,8 +746,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

@ -5300,13 +5300,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;
}
@ -5545,13 +5543,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 {
@ -5561,13 +5559,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;
@ -5645,8 +5644,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() {
@ -5654,18 +5655,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);
}
}
@ -5769,6 +5787,7 @@ export class CurseAttr extends MoveEffectAttr {
}
}
// TODO: Delete this and make mortal spin use `RemoveBattlerTagAttr`
export class LapseBattlerTagAttr extends MoveEffectAttr {
public tagTypes: BattlerTagType[];
@ -7991,7 +8010,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();
@ -8022,6 +8046,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) {
@ -8203,9 +8231,6 @@ export class ExposedMoveAttr extends AddBattlerTagAttr {
}
}
const unknownTypeCondition: MoveConditionFunc = (user, target, move) => !user.getTypes().includes(PokemonType.UNKNOWN);
export type MoveTargetSet = {
targets: BattlerIndex[];
multiple: boolean;
@ -8855,7 +8880,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(),
@ -9299,6 +9323,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)
@ -9553,9 +9578,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()
@ -9685,7 +9710,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)
@ -9914,12 +9940,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()
@ -9927,8 +9953,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)
@ -10389,8 +10413,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, -1, 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

@ -278,20 +278,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;
}
/**
@ -302,14 +300,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() &&
@ -333,7 +332,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?
@ -394,25 +393,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)) {
@ -420,7 +418,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!),
@ -454,6 +452,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

@ -2283,13 +2283,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)))
);
}
@ -2489,7 +2505,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);
@ -3756,6 +3772,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.scene.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.scene.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.scene.getPlayerPokemon()!;
const snorlax = game.scene.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.scene.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);
});
});

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

@ -0,0 +1,401 @@
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 I figure out how to force 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.getTag(BattlerTagType.CONFUSED)).toBeUndefined();
expect(shuckle.getTag(BattlerTagType.CONFUSED)).toBeDefined();
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.hp).toBeLessThan(blissey.getMaxHp());
expect(blissey.getTag(BattlerTagType.CONFUSED)).toBeUndefined();
expect(blissey.status?.effect).toBeUndefined();
expect(shuckle.getLastXMoves()[0].result).toBe(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),
}),
);
});
// TODO: Move over from #6136 once it is merged
it.todo.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.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.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.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.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.scene.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.scene.getPlayerPokemon()!;
const enemyPokemon = game.scene.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";
@ -34,24 +35,10 @@ describe("Moves - Magnet Rise", () => {
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.scene.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.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemy.getTag(BattlerTagType.FLOATING)).toBeDefined();
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 render the target immune to Ground-type Moves and terrain", async () => {
game.override.terrain(TerrainType.ELECTRIC);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyOpponent = game.scene.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.scene.getEnemyPokemon()!;
const enemyOpponent = game.field.getEnemyPokemon();
game.move.use(MoveId.TELEKINESIS);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
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 () => {
it("should become grounded when Ingrain is used, but not remove the guaranteed hit effect", async () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
const enemyOpponent = game.scene.getEnemyPokemon()!;
game.move.select(MoveId.TELEKINESIS);
await game.phaseInterceptor.to("TurnEndPhase");
expect(enemyOpponent.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemyOpponent.getTag(BattlerTagType.FLOATING)).toBeDefined();
const playerPokemon = game.field.getPlayerPokemon();
const enemy = game.field.getEnemyPokemon();
game.move.use(MoveId.TELEKINESIS);
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]);
await game.classicMode.startBattle([SpeciesId.MAGIKARP]);
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();
const playerPokemon = game.scene.getPlayerPokemon()!;
const enemyOpponent = game.scene.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();
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(enemy.getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
expect(enemy.getTag(BattlerTagType.INGRAIN)).toBeDefined();
expect(enemy.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined();
expect(enemy.getTag(BattlerTagType.FLOATING)).toBeUndefined();
expect(enemy.isGrounded()).toBe(true);
expect(playerPokemon.getLastXMoves()[0].result).toBe(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.scene.getPlayerPokemon()!.getTag(BattlerTagType.TELEKINESIS)).toBeUndefined();
await game.phaseInterceptor.to("MoveEndPhase");
expect(game.field.getPlayerPokemon().getTag(BattlerTagType.TELEKINESIS)).toBeDefined();
await game.toEndOfTurn();
expect(game.field.getPlayerPokemon().getTag(BattlerTagType.TELEKINESIS)).toBeUndefined();
});
});

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.scene.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.scene.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.scene.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 { PostGameOverPhase } from "#phases/post-game-over-phase";
import { PostSummonPhase } from "#phases/post-summon-phase";
@ -144,6 +145,7 @@ export class PhaseInterceptor {
[SelectBiomePhase, this.startPhase],
[PokemonTransformPhase, this.startPhase],
[MysteryEncounterPhase, this.startPhase],
[PokemonHealPhase, this.startPhase],
[MysteryEncounterOptionSelectedPhase, this.startPhase],
[MysteryEncounterBattlePhase, this.startPhase],
[MysteryEncounterRewardsPhase, this.startPhase],