From 4885a6abc5c437b0ce55e4ff5adc8bdb3dd7a92f Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 5 Aug 2025 17:33:58 -0400 Subject: [PATCH 1/3] Added `toBeConfused` matcher --- src/data/arena-tag.ts | 15 +- src/data/battler-tags.ts | 9 +- src/data/moves/move.ts | 98 +++-- src/data/terrain.ts | 16 +- src/data/weather.ts | 16 +- src/field/arena.ts | 54 ++- src/field/pokemon.ts | 23 +- src/overrides.ts | 7 + src/phases/encounter-phase.ts | 1 + src/phases/weather-effect-phase.ts | 2 +- test/arena/arena-gravity.test.ts | 129 +----- test/arena/grassy-terrain.test.ts | 69 --- test/arena/psychic-terrain.test.ts | 59 --- test/arena/terrain.test.ts | 400 ++++++++++++++++++ test/moves/fly-bounce.test.ts | 131 ++++++ test/moves/fly.test.ts | 120 ------ test/moves/magnet-rise.test.ts | 21 +- test/moves/smack-down-thousand-arrows.test.ts | 137 ++++++ test/moves/telekinesis.test.ts | 154 +++---- test/moves/thousand-arrows.test.ts | 89 ---- test/test-utils/helpers/overrides-helper.ts | 15 + test/test-utils/phase-interceptor.ts | 2 + 22 files changed, 953 insertions(+), 614 deletions(-) delete mode 100644 test/arena/grassy-terrain.test.ts delete mode 100644 test/arena/psychic-terrain.test.ts create mode 100644 test/arena/terrain.test.ts create mode 100644 test/moves/fly-bounce.test.ts delete mode 100644 test/moves/fly.test.ts create mode 100644 test/moves/smack-down-thousand-arrows.test.ts delete mode 100644 test/moves/thousand-arrows.test.ts diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 15c2cde1d58..9e2f298549e 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -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); } }); } diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 455beec6901..2af1a12092a 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -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; } diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 0dfbc78d7ae..925ef0700cd 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -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) diff --git a/src/data/terrain.ts b/src/data/terrain.ts index 7906450d0ea..d82ac2bcd74 100644 --- a/src/data/terrain.ts +++ b/src/data/terrain.ts @@ -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 { diff --git a/src/data/weather.ts b/src/data/weather.ts index 59be56826a4..4f78025fddd 100644 --- a/src/data/weather.ts +++ b/src/data/weather.ts @@ -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: diff --git a/src/field/arena.ts b/src/field/arena.ts index 484450cc5df..c87d7b157fc 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -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); } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index d4f332d887c..c3142559e5d 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -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()), ); diff --git a/src/overrides.ts b/src/overrides.ts index de0d1d3f30a..ef3dc5d9773 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -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. * diff --git a/src/phases/encounter-phase.ts b/src/phases/encounter-phase.ts index 79da7134e9a..dae5c631b1c 100644 --- a/src/phases/encounter-phase.ts +++ b/src/phases/encounter-phase.ts @@ -690,6 +690,7 @@ export class EncounterPhase extends BattlePhase { trySetWeatherIfNewBiome(): void { if (!this.loaded) { globalScene.arena.trySetWeather(getRandomWeatherType(globalScene.arena)); + globalScene.arena.tryOverrideTerrain(); } } } diff --git a/src/phases/weather-effect-phase.ts b/src/phases/weather-effect-phase.ts index 81db543001b..2174778b21f 100644 --- a/src/phases/weather-effect-phase.ts +++ b/src/phases/weather-effect-phase.ts @@ -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( diff --git a/test/arena/arena-gravity.test.ts b/test/arena/arena-gravity.test.ts index 02b49d7711f..19627fb92f6 100644 --- a/test/arena/arena-gravity.test.ts +++ b/test/arena/arena-gravity.test.ts @@ -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); }); }); diff --git a/test/arena/grassy-terrain.test.ts b/test/arena/grassy-terrain.test.ts deleted file mode 100644 index d87498cdd23..00000000000 --- a/test/arena/grassy-terrain.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/test/arena/psychic-terrain.test.ts b/test/arena/psychic-terrain.test.ts deleted file mode 100644 index 6d42ed0d3ac..00000000000 --- a/test/arena/psychic-terrain.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/test/arena/terrain.test.ts b/test/arena/terrain.test.ts new file mode 100644 index 00000000000..28b137d4f09 --- /dev/null +++ b/test/arena/terrain.test.ts @@ -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(); + }); + }); +}); diff --git a/test/moves/fly-bounce.test.ts b/test/moves/fly-bounce.test.ts new file mode 100644 index 00000000000..42d88c13345 --- /dev/null +++ b/test/moves/fly-bounce.test.ts @@ -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()); + }); +}); diff --git a/test/moves/fly.test.ts b/test/moves/fly.test.ts deleted file mode 100644 index dc40b4a439b..00000000000 --- a/test/moves/fly.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/test/moves/magnet-rise.test.ts b/test/moves/magnet-rise.test.ts index 181113b5328..c98c008e4c0 100644 --- a/test/moves/magnet-rise.test.ts +++ b/test/moves/magnet-rise.test.ts @@ -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); }); }); diff --git a/test/moves/smack-down-thousand-arrows.test.ts b/test/moves/smack-down-thousand-arrows.test.ts new file mode 100644 index 00000000000..a90c242c3e1 --- /dev/null +++ b/test/moves/smack-down-thousand-arrows.test.ts @@ -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()); + }); + }); +}); diff --git a/test/moves/telekinesis.test.ts b/test/moves/telekinesis.test.ts index f14c42d1dcc..a7eca81dd01 100644 --- a/test/moves/telekinesis.test.ts +++ b/test/moves/telekinesis.test.ts @@ -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); }); }); diff --git a/test/moves/thousand-arrows.test.ts b/test/moves/thousand-arrows.test.ts deleted file mode 100644 index 47bdce8476c..00000000000 --- a/test/moves/thousand-arrows.test.ts +++ /dev/null @@ -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()); - }); -}); diff --git a/test/test-utils/helpers/overrides-helper.ts b/test/test-utils/helpers/overrides-helper.ts index d67ceedf891..5f8ec4a142f 100644 --- a/test/test-utils/helpers/overrides-helper.ts +++ b/test/test-utils/helpers/overrides-helper.ts @@ -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 diff --git a/test/test-utils/phase-interceptor.ts b/test/test-utils/phase-interceptor.ts index 50de7e9f047..dacd0f3fdc6 100644 --- a/test/test-utils/phase-interceptor.ts +++ b/test/test-utils/phase-interceptor.ts @@ -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], From 314f46a22ba346ecb85c879e5f05a4602995d275 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 5 Aug 2025 18:10:34 -0400 Subject: [PATCH 2/3] Fixed tests and such --- src/data/moves/move.ts | 5 ++- test/abilities/shields-down.test.ts | 28 ++++++------- test/moves/fly-bounce.test.ts | 19 ++++----- test/moves/smack-down-thousand-arrows.test.ts | 39 +++++++------------ 4 files changed, 40 insertions(+), 51 deletions(-) diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 925ef0700cd..c442d40fa07 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1533,6 +1533,7 @@ export class CritOnlyAttr extends MoveAttr { } } +// TODO: Fix subclasses to actually extend from `getDamage` export class FixedDamageAttr extends MoveAttr { private damage: number; @@ -5935,8 +5936,8 @@ export class ProtectAttr extends AddBattlerTagAttr { for (const turnMove of user.getLastXMoves(-1).slice()) { if ( // Quick & Wide guard increment the Protect counter without using it for fail chance - !(allMoves[turnMove.move].hasAttr("ProtectAttr") || - [MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) || + !(allMoves[turnMove.move].hasAttr("ProtectAttr") || + [MoveId.QUICK_GUARD, MoveId.WIDE_GUARD].includes(turnMove.move)) || turnMove.result !== MoveResult.SUCCESS ) { break; diff --git a/test/abilities/shields-down.test.ts b/test/abilities/shields-down.test.ts index 98a1cfffa8e..2eb7359edd1 100644 --- a/test/abilities/shields-down.test.ts +++ b/test/abilities/shields-down.test.ts @@ -1,5 +1,6 @@ import { Status } from "#data/status-effect"; import { AbilityId } from "#enums/ability-id"; +import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type"; import { MoveId } from "#enums/move-id"; import { SpeciesId } from "#enums/species-id"; @@ -104,27 +105,26 @@ describe("Abilities - SHIELDS DOWN", () => { expect(game.field.getPlayerPokemon().status).toBe(undefined); }); - // toxic spikes currently does not poison flying types when gravity is in effect - test.todo("should become poisoned by toxic spikes when grounded", async () => { - game.override - .enemyMoveset([MoveId.GRAVITY, MoveId.TOXIC_SPIKES, MoveId.SPLASH]) - .moveset([MoveId.GRAVITY, MoveId.SPLASH]); - + it("should be poisoned by toxic spikes when Gravity is active before changing forms", async () => { await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.MINIOR]); - // turn 1 - game.move.select(MoveId.GRAVITY); - await game.move.selectEnemyMove(MoveId.TOXIC_SPIKES); + // Change minior to core form in a state where it would revert on switch + const minior = game.scene.getPlayerParty()[1]; + minior.formIndex = redCoreForm; + + game.move.use(MoveId.GRAVITY); + await game.move.forceEnemyMove(MoveId.TOXIC_SPIKES); await game.toNextTurn(); - // turn 2 + expect(game).toHaveArenaTag(ArenaTagType.GRAVITY); + game.doSwitchPokemon(1); - await game.move.selectEnemyMove(MoveId.SPLASH); await game.toNextTurn(); - expect(game.field.getPlayerPokemon().species.speciesId).toBe(SpeciesId.MINIOR); - expect(game.field.getPlayerPokemon().species.formIndex).toBe(0); - expect(game.field.getPlayerPokemon().status?.effect).toBe(StatusEffect.POISON); + expect(minior.species.speciesId).toBe(SpeciesId.MINIOR); + expect(minior.formIndex).toBe(0); + expect(minior.isGrounded()).toBe(true); + expect(minior).toHaveStatusEffect(StatusEffect.POISON); }); test("should ignore yawn", async () => { diff --git a/test/moves/fly-bounce.test.ts b/test/moves/fly-bounce.test.ts index 42d88c13345..e792938ba88 100644 --- a/test/moves/fly-bounce.test.ts +++ b/test/moves/fly-bounce.test.ts @@ -48,14 +48,14 @@ describe("Moves - Fly and Bounce", () => { const player = game.field.getPlayerPokemon(); const enemy = game.field.getEnemyPokemon(); - expect(player.getTag(BattlerTagType.FLYING)).toBeDefined(); + expect(player).toHaveBattlerTag(BattlerTagType.FLYING); 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(player).not.toHaveBattlerTag(BattlerTagType.FLYING); expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); expect(player.getMoveHistory()).toHaveLength(2); @@ -63,6 +63,7 @@ describe("Moves - Fly and Bounce", () => { expect(playerFly?.ppUsed).toBe(1); }); + // TODO: Move to a No Guard test file it("should not allow the user to evade attacks from Pokemon with No Guard", async () => { game.override.enemyAbility(AbilityId.NO_GUARD); @@ -71,7 +72,7 @@ describe("Moves - Fly and Bounce", () => { const playerPokemon = game.field.getPlayerPokemon(); const enemyPokemon = game.field.getEnemyPokemon(); - game.move.select(MoveId.FLY); + game.move.use(MoveId.FLY); await game.toEndOfTurn(); expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp()); @@ -85,10 +86,10 @@ describe("Moves - Fly and Bounce", () => { const playerPokemon = game.field.getPlayerPokemon(); - game.move.select(MoveId.FLY); + game.move.use(MoveId.FLY); await game.toEndOfTurn(); - expect(playerPokemon.getTag(BattlerTagType.FLYING)).toBeUndefined(); + expect(playerPokemon).not.toHaveBattlerTag(BattlerTagType.FLYING); expect(playerPokemon.status?.effect).toBe(StatusEffect.SLEEP); const playerFly = playerPokemon.getMoveset().find(mv => mv && mv.moveId === MoveId.FLY); @@ -110,13 +111,13 @@ describe("Moves - Fly and Bounce", () => { // Bounce should've worked until hit const azurill = game.field.getPlayerPokemon(); - expect(azurill.getTag(BattlerTagType.FLYING)).toBeDefined(); - expect(azurill.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined(); + expect(azurill).toHaveBattlerTag(BattlerTagType.FLYING); + expect(azurill).not.toHaveBattlerTag(BattlerTagType.IGNORE_FLYING); await game.phaseInterceptor.to("MoveEndPhase"); - expect(azurill.getTag(BattlerTagType.FLYING)).toBeUndefined(); - expect(azurill.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); + expect(azurill).not.toHaveBattlerTag(BattlerTagType.FLYING); + expect(azurill).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING); expect(azurill.getMoveQueue()).toHaveLength(0); expect(azurill.visible).toBe(true); if (move !== MoveId.GRAVITY) { diff --git a/test/moves/smack-down-thousand-arrows.test.ts b/test/moves/smack-down-thousand-arrows.test.ts index a90c242c3e1..c394b889dfd 100644 --- a/test/moves/smack-down-thousand-arrows.test.ts +++ b/test/moves/smack-down-thousand-arrows.test.ts @@ -46,10 +46,9 @@ describe("Moves - Smack Down and Thousand Arrows", () => { 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).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING); expect(enemy.isGrounded()).toBe(true); }); @@ -61,45 +60,33 @@ describe("Moves - Smack Down and Thousand Arrows", () => { 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).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING); 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 }) => { + { name: "TELEKINESIS", tag: BattlerTagType.TELEKINESIS }, + { name: "FLOATING", tag: BattlerTagType.FLOATING }, + ])("should cancel the effects of BattlerTagType.$name", async ({ tag }) => { 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); + eelektross.addTag(tag); + game.move.use(MoveId.SMACK_DOWN); 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); + expect(eelektross).not.toHaveBattlerTag(tag); + expect(eelektross).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING); }); // 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 () => { + it("should not ground semi-invulnerable targets hit via No Guard unless already ungrounded", async () => { + game.override.ability(AbilityId.NO_GUARD); await game.classicMode.startBattle([SpeciesId.ILLUMISE]); game.move.use(MoveId.THOUSAND_ARROWS); @@ -110,7 +97,7 @@ describe("Moves - Smack Down and Thousand Arrows", () => { // 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).not.toHaveBattlerTag(BattlerTagType.IGNORE_FLYING); expect(eelektross.hp).toBeLessThan(eelektross.getMaxHp()); }); @@ -129,7 +116,7 @@ describe("Moves - Smack Down and Thousand Arrows", () => { await game.toEndOfTurn(); expect(hitSpy).toHaveReturnedWith([expect.anything(), 1]); - expect(archeops.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); + expect(archeops).toHaveBattlerTag(BattlerTagType.IGNORE_FLYING); expect(archeops.isGrounded()).toBe(true); expect(archeops.hp).toBeLessThan(archeops.getMaxHp()); }); From a29161c2ed5f5dd7491a274f52856360998017ae Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 5 Aug 2025 22:12:35 -0400 Subject: [PATCH 3/3] Removed bogus PB test --- test/abilities/parental-bond.test.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/test/abilities/parental-bond.test.ts b/test/abilities/parental-bond.test.ts index a72fc82260f..3700fd7450c 100644 --- a/test/abilities/parental-bond.test.ts +++ b/test/abilities/parental-bond.test.ts @@ -278,27 +278,6 @@ describe("Abilities - Parental Bond", () => { expect(enemyPokemon.getTag(BattlerTagType.TRAPPED)).toBeDefined(); }); - it("Smack Down boosted by this ability should only ground the target after the second hit", async () => { - game.override.moveset([MoveId.SMACK_DOWN]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.field.getPlayerPokemon(); - const enemyPokemon = game.field.getEnemyPokemon(); - - game.move.select(MoveId.SMACK_DOWN); - await game.move.forceHit(); - - await game.phaseInterceptor.to("DamageAnimPhase"); - - expect(leadPokemon.turnData.hitCount).toBe(2); - expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeUndefined(); - - await game.phaseInterceptor.to("TurnEndPhase"); - - expect(enemyPokemon.getTag(BattlerTagType.IGNORE_FLYING)).toBeDefined(); - }); - it("U-turn boosted by this ability should strike twice before forcing a switch", async () => { game.override.moveset([MoveId.U_TURN]);