diff --git a/src/data/ability.ts b/src/data/ability.ts index 022e2df0502..8b7c3a1b04d 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -13,7 +13,7 @@ import { ArenaTagSide, ArenaTrapTag } from "./arena-tag"; import { Stat, getStatName } from "./pokemon-stat"; import { BerryModifier, PokemonHeldItemModifier } from "../modifier/modifier"; import { TerrainType } from "./terrain"; -import { SpeciesFormChangeManualTrigger } from "./pokemon-forms"; +import { SpeciesFormChangeManualTrigger, SpeciesFormChangeRevertWeatherFormTrigger, SpeciesFormChangeWeatherTrigger } from "./pokemon-forms"; import i18next from "i18next"; import { Localizable } from "#app/interfaces/locales.js"; import { Command } from "../ui/command-ui-handler"; @@ -25,10 +25,11 @@ import { ArenaTagType } from "#enums/arena-tag-type"; import { BattlerTagType } from "#enums/battler-tag-type"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; -import { MovePhase } from "#app/phases/move-phase.js"; -import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase.js"; -import { ShowAbilityPhase } from "#app/phases/show-ability-phase.js"; -import { StatChangePhase } from "#app/phases/stat-change-phase.js"; +import { MovePhase } from "#app/phases/move-phase"; +import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase"; +import { ShowAbilityPhase } from "#app/phases/show-ability-phase"; +import { StatChangePhase } from "#app/phases/stat-change-phase"; +import BattleScene from "#app/battle-scene"; export class Ability implements Localizable { public id: Abilities; @@ -2361,6 +2362,73 @@ export class PostSummonTransformAbAttr extends PostSummonAbAttr { } } +/** + * Reverts weather-based forms to their normal forms when the user is summoned. + * Used by Cloud Nine and Air Lock. + * @extends PostSummonAbAttr + */ +export class PostSummonWeatherSuppressedFormChangeAbAttr extends PostSummonAbAttr { + /** + * Triggers {@linkcode Arena.triggerWeatherBasedFormChangesToNormal | triggerWeatherBasedFormChangesToNormal} + * @param {Pokemon} pokemon the Pokemon with this ability + * @param passive n/a + * @param args n/a + * @returns whether a Pokemon was reverted to its normal form + */ + applyPostSummon(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]) { + const pokemonToTransform = getPokemonWithWeatherBasedForms(pokemon.scene); + + if (pokemonToTransform.length < 1) { + return false; + } + + if (!simulated) { + pokemon.scene.arena.triggerWeatherBasedFormChangesToNormal(); + } + + return true; + } +} + +/** + * Triggers weather-based form change when summoned into an active weather. + * Used by Forecast. + * @extends PostSummonAbAttr + */ +export class PostSummonFormChangeByWeatherAbAttr extends PostSummonAbAttr { + private ability: Abilities; + + constructor(ability: Abilities) { + super(false); + + this.ability = ability; + } + + /** + * Calls the {@linkcode BattleScene.triggerPokemonFormChange | triggerPokemonFormChange} for both + * {@linkcode SpeciesFormChange.SpeciesFormChangeWeatherTrigger | SpeciesFormChangeWeatherTrigger} and + * {@linkcode SpeciesFormChange.SpeciesFormChangeWeatherTrigger | SpeciesFormChangeRevertWeatherFormTrigger} if it + * is the specific Pokemon and ability + * @param {Pokemon} pokemon the Pokemon with this ability + * @param passive n/a + * @param args n/a + * @returns whether the form change was triggered + */ + applyPostSummon(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): boolean { + if (pokemon.species.speciesId === Species.CASTFORM && this.ability === Abilities.FORECAST) { + if (simulated) { + return simulated; + } + + pokemon.scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeWeatherTrigger); + pokemon.scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeRevertWeatherFormTrigger); + queueShowAbility(pokemon, passive); + return true; + } + return false; + } +} + export class PreSwitchOutAbAttr extends AbAttr { constructor() { super(true); @@ -3014,6 +3082,49 @@ export class PostWeatherChangeAbAttr extends AbAttr { } } +/** + * Triggers weather-based form change when weather changes. + * Used by Forecast. + * @extends PostWeatherChangeAbAttr + */ +export class PostWeatherChangeFormChangeAbAttr extends PostWeatherChangeAbAttr { + private ability: Abilities; + + constructor(ability: Abilities) { + super(false); + + this.ability = ability; + } + + /** + * Calls {@linkcode Arena.triggerWeatherBasedFormChangesToNormal | triggerWeatherBasedFormChangesToNormal} when the + * weather changed to form-reverting weather, otherwise calls {@linkcode Arena.triggerWeatherBasedFormChanges | triggerWeatherBasedFormChanges} + * @param {Pokemon} pokemon the Pokemon that changed the weather + * @param passive n/a + * @param weather n/a + * @param args n/a + * @returns whether the form change was triggered + */ + applyPostWeatherChange(pokemon: Pokemon, passive: boolean, simulated: boolean, weather: WeatherType, args: any[]): boolean { + if (pokemon.species.speciesId === Species.CASTFORM && this.ability === Abilities.FORECAST) { + if (simulated) { + return simulated; + } + + const formRevertingWeathers: WeatherType[] = [ WeatherType.NONE, WeatherType.SANDSTORM, WeatherType.STRONG_WINDS, WeatherType.FOG ]; + const weatherType = pokemon.scene.arena.weather?.weatherType; + + if (weatherType && formRevertingWeathers.includes(weatherType)) { + pokemon.scene.arena.triggerWeatherBasedFormChangesToNormal(); + } else { + pokemon.scene.arena.triggerWeatherBasedFormChanges(); + } + return true; + } + return false; + } +} + export class PostWeatherChangeAddBattlerTagAttr extends PostWeatherChangeAbAttr { private tagType: BattlerTagType; private turnCount: integer; @@ -3784,6 +3895,38 @@ export class PostFaintAbAttr extends AbAttr { } } +/** + * Used for weather suppressing abilities to trigger weather-based form changes upon being fainted. + * Used by Cloud Nine and Air Lock. + * @extends PostFaintAbAttr + */ +export class PostFaintUnsuppressedWeatherFormChangeAbAttr extends PostFaintAbAttr { + /** + * Triggers {@linkcode Arena.triggerWeatherBasedFormChanges | triggerWeatherBasedFormChanges} + * when the user of the ability faints + * @param {Pokemon} pokemon the fainted Pokemon + * @param passive n/a + * @param attacker n/a + * @param move n/a + * @param hitResult n/a + * @param args n/a + * @returns whether the form change was triggered + */ + applyPostFaint(pokemon: Pokemon, passive: boolean, simulated: boolean, attacker: Pokemon, move: Move, hitResult: HitResult, args: any[]): boolean { + const pokemonToTransform = getPokemonWithWeatherBasedForms(pokemon.scene); + + if (pokemonToTransform.length < 1) { + return false; + } + + if (!simulated) { + pokemon.scene.arena.triggerWeatherBasedFormChanges(); + } + + return true; + } +} + /** * Clears Desolate Land/Primordial Sea/Delta Stream upon the Pokemon fainting */ @@ -4559,6 +4702,16 @@ function setAbilityRevealed(pokemon: Pokemon): void { } } +/** + * Returns the Pokemon with weather-based forms + * @param {BattleScene} scene - The current scene + */ +function getPokemonWithWeatherBasedForms(scene: BattleScene) { + return scene.getField(true).filter(p => + p.hasAbility(Abilities.FORECAST) && p.species.speciesId === Species.CASTFORM + ); +} + export const allAbilities = [ new Ability(Abilities.NONE, 3) ]; export function initAbilities() { @@ -4605,7 +4758,10 @@ export function initAbilities() { .ignorable(), new Ability(Abilities.CLOUD_NINE, 3) .attr(SuppressWeatherEffectAbAttr, true) - .attr(PostSummonUnnamedMessageAbAttr, i18next.t("abilityTriggers:weatherEffectDisappeared")), + .attr(PostSummonUnnamedMessageAbAttr, i18next.t("abilityTriggers:weatherEffectDisappeared")) + .attr(PostSummonWeatherSuppressedFormChangeAbAttr) + .attr(PostFaintUnsuppressedWeatherFormChangeAbAttr) + .bypassFaint(), new Ability(Abilities.COMPOUND_EYES, 3) .attr(BattleStatMultiplierAbAttr, BattleStat.ACC, 1.3), new Ability(Abilities.INSOMNIA, 3) @@ -4750,7 +4906,8 @@ export function initAbilities() { new Ability(Abilities.FORECAST, 3) .attr(UncopiableAbilityAbAttr) .attr(NoFusionAbilityAbAttr) - .unimplemented(), + .attr(PostSummonFormChangeByWeatherAbAttr, Abilities.FORECAST) + .attr(PostWeatherChangeFormChangeAbAttr, Abilities.FORECAST), new Ability(Abilities.STICKY_HOLD, 3) .attr(BlockItemTheftAbAttr) .bypassFaint() @@ -4800,7 +4957,10 @@ export function initAbilities() { .ignorable(), new Ability(Abilities.AIR_LOCK, 3) .attr(SuppressWeatherEffectAbAttr, true) - .attr(PostSummonUnnamedMessageAbAttr, i18next.t("abilityTriggers:weatherEffectDisappeared")), + .attr(PostSummonUnnamedMessageAbAttr, i18next.t("abilityTriggers:weatherEffectDisappeared")) + .attr(PostSummonWeatherSuppressedFormChangeAbAttr) + .attr(PostFaintUnsuppressedWeatherFormChangeAbAttr) + .bypassFaint(), new Ability(Abilities.TANGLED_FEET, 4) .conditionalAttr(pokemon => !!pokemon.getTag(BattlerTagType.CONFUSED), BattleStatMultiplierAbAttr, BattleStat.EVA, 2) .ignorable(), diff --git a/src/data/move.ts b/src/data/move.ts index 48cc03a5868..303fb3d5c7a 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -26,16 +26,17 @@ import { BattlerTagType } from "#enums/battler-tag-type"; import { Biome } from "#enums/biome"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; -import { MoveUsedEvent } from "#app/events/battle-scene.js"; -import { PartyStatusCurePhase } from "#app/phases/party-status-cure-phase.js"; -import { BattleEndPhase } from "#app/phases/battle-end-phase.js"; -import { MoveEndPhase } from "#app/phases/move-end-phase.js"; -import { MovePhase } from "#app/phases/move-phase.js"; -import { NewBattlePhase } from "#app/phases/new-battle-phase.js"; -import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase.js"; -import { StatChangePhase } from "#app/phases/stat-change-phase.js"; -import { SwitchPhase } from "#app/phases/switch-phase.js"; -import { SwitchSummonPhase } from "#app/phases/switch-summon-phase.js"; +import { MoveUsedEvent } from "#app/events/battle-scene"; +import { PartyStatusCurePhase } from "#app/phases/party-status-cure-phase"; +import { BattleEndPhase } from "#app/phases/battle-end-phase"; +import { MoveEndPhase } from "#app/phases/move-end-phase"; +import { MovePhase } from "#app/phases/move-phase"; +import { NewBattlePhase } from "#app/phases/new-battle-phase"; +import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase"; +import { StatChangePhase } from "#app/phases/stat-change-phase"; +import { SwitchPhase } from "#app/phases/switch-phase"; +import { SwitchSummonPhase } from "#app/phases/switch-summon-phase"; +import { SpeciesFormChangeRevertWeatherFormTrigger } from "./pokemon-forms"; export enum MoveCategory { PHYSICAL, @@ -5739,7 +5740,10 @@ export class AbilityChangeAttr extends MoveEffectAttr { return false; } - (this.selfTarget ? user : target).summonData.ability = this.ability; + const moveTarget = this.selfTarget ? user : target; + + moveTarget.summonData.ability = this.ability; + user.scene.triggerPokemonFormChange(moveTarget, SpeciesFormChangeRevertWeatherFormTrigger); user.scene.queueMessage(i18next.t("moveTriggers:acquiredAbility", {pokemonName: getPokemonNameWithAffix((this.selfTarget ? user : target)), abilityName: allAbilities[this.ability].name})); @@ -5825,6 +5829,10 @@ export class SwitchAbilitiesAttr extends MoveEffectAttr { target.summonData.ability = tempAbilityId; user.scene.queueMessage(i18next.t("moveTriggers:swappedAbilitiesWithTarget", {pokemonName: getPokemonNameWithAffix(user)})); + // Swaps Forecast from Castform + user.scene.arena.triggerWeatherBasedFormChangesToNormal(); + // Swaps Forecast to Castform (edge case) + user.scene.arena.triggerWeatherBasedFormChanges(); return true; } @@ -5850,6 +5858,7 @@ export class SuppressAbilitiesAttr extends MoveEffectAttr { } target.summonData.abilitySuppressed = true; + target.scene.arena.triggerWeatherBasedFormChangesToNormal(); target.scene.queueMessage(i18next.t("moveTriggers:suppressAbilities", {pokemonName: getPokemonNameWithAffix(target)})); diff --git a/src/data/pokemon-forms.ts b/src/data/pokemon-forms.ts index 95a89c7c640..e4417f8e8bb 100644 --- a/src/data/pokemon-forms.ts +++ b/src/data/pokemon-forms.ts @@ -10,6 +10,7 @@ import { Species } from "#enums/species"; import { TimeOfDay } from "#enums/time-of-day"; import { getPokemonNameWithAffix } from "#app/messages.js"; import i18next from "i18next"; +import { WeatherType } from "./weather"; export enum FormChangeItem { NONE, @@ -356,6 +357,78 @@ export class SpeciesDefaultFormMatchTrigger extends SpeciesFormChangeTrigger { } } +/** + * Class used for triggering form changes based on weather. + * Used by Castform. + * @extends SpeciesFormChangeTrigger + */ +export class SpeciesFormChangeWeatherTrigger extends SpeciesFormChangeTrigger { + /** The ability that triggers the form change */ + public ability: Abilities; + /** The list of weathers that trigger the form change */ + public weathers: WeatherType[]; + + constructor(ability: Abilities, weathers: WeatherType[]) { + super(); + this.ability = ability; + this.weathers = weathers; + } + + /** + * Checks if the Pokemon has the required ability and is in the correct weather while + * the weather or ability is also not suppressed. + * @param {Pokemon} pokemon the pokemon that is trying to do the form change + * @returns `true` if the Pokemon can change forms, `false` otherwise + */ + canChange(pokemon: Pokemon): boolean { + const currentWeather = pokemon.scene.arena.weather?.weatherType ?? WeatherType.NONE; + const isWeatherSuppressed = pokemon.scene.arena.weather?.isEffectSuppressed(pokemon.scene); + const isAbilitySuppressed = pokemon.summonData.abilitySuppressed; + + return !isAbilitySuppressed && !isWeatherSuppressed && (pokemon.hasAbility(this.ability) && this.weathers.includes(currentWeather)); + } +} + +/** + * Class used for reverting to the original form when the weather runs out + * or when the user loses the ability/is suppressed. + * Used by Castform. + * @extends SpeciesFormChangeTrigger + */ +export class SpeciesFormChangeRevertWeatherFormTrigger extends SpeciesFormChangeTrigger { + /** The ability that triggers the form change*/ + public ability: Abilities; + /** The list of weathers that will also trigger a form change to original form */ + public weathers: WeatherType[]; + + constructor(ability: Abilities, weathers: WeatherType[]) { + super(); + this.ability = ability; + this.weathers = weathers; + } + + /** + * Checks if the Pokemon has the required ability and the weather is one that will revert + * the Pokemon to its original form or the weather or ability is suppressed + * @param {Pokemon} pokemon the pokemon that is trying to do the form change + * @returns `true` if the Pokemon will revert to its original form, `false` otherwise + */ + canChange(pokemon: Pokemon): boolean { + if (pokemon.hasAbility(this.ability, false, true)) { + const currentWeather = pokemon.scene.arena.weather?.weatherType ?? WeatherType.NONE; + const isWeatherSuppressed = pokemon.scene.arena.weather?.isEffectSuppressed(pokemon.scene); + const isAbilitySuppressed = pokemon.summonData.abilitySuppressed; + const summonDataAbility = pokemon.summonData.ability; + const isAbilityChanged = summonDataAbility !== this.ability && summonDataAbility !== Abilities.NONE; + + if (this.weathers.includes(currentWeather) || isWeatherSuppressed || isAbilitySuppressed || isAbilityChanged) { + return true; + } + } + return false; + } +} + export function getSpeciesFormChangeMessage(pokemon: Pokemon, formChange: SpeciesFormChange, preName: string): string { const isMega = formChange.formKey.indexOf(SpeciesFormKey.MEGA) > -1; const isGmax = formChange.formKey.indexOf(SpeciesFormKey.GIGANTAMAX) > -1; @@ -839,7 +912,24 @@ export const pokemonFormChanges: PokemonFormChanges = { new SpeciesFormChange(Species.CRAMORANT, "gorging", "", new SpeciesFormChangeManualTrigger, true), new SpeciesFormChange(Species.CRAMORANT, "gulping", "", new SpeciesFormChangeActiveTrigger(false), true), new SpeciesFormChange(Species.CRAMORANT, "gorging", "", new SpeciesFormChangeActiveTrigger(false), true), - ] + ], + [Species.CASTFORM]: [ + new SpeciesFormChange(Species.CASTFORM, "", "sunny", new SpeciesFormChangeWeatherTrigger(Abilities.FORECAST, [WeatherType.SUNNY, WeatherType.HARSH_SUN]), true), + new SpeciesFormChange(Species.CASTFORM, "rainy", "sunny", new SpeciesFormChangeWeatherTrigger(Abilities.FORECAST, [WeatherType.SUNNY, WeatherType.HARSH_SUN]), true), + new SpeciesFormChange(Species.CASTFORM, "snowy", "sunny", new SpeciesFormChangeWeatherTrigger(Abilities.FORECAST, [WeatherType.SUNNY, WeatherType.HARSH_SUN]), true), + new SpeciesFormChange(Species.CASTFORM, "", "rainy", new SpeciesFormChangeWeatherTrigger(Abilities.FORECAST, [WeatherType.RAIN, WeatherType.HEAVY_RAIN]), true), + new SpeciesFormChange(Species.CASTFORM, "sunny", "rainy", new SpeciesFormChangeWeatherTrigger(Abilities.FORECAST, [WeatherType.RAIN, WeatherType.HEAVY_RAIN]), true), + new SpeciesFormChange(Species.CASTFORM, "snowy", "rainy", new SpeciesFormChangeWeatherTrigger(Abilities.FORECAST, [WeatherType.RAIN, WeatherType.HEAVY_RAIN]), true), + new SpeciesFormChange(Species.CASTFORM, "", "snowy", new SpeciesFormChangeWeatherTrigger(Abilities.FORECAST, [WeatherType.HAIL, WeatherType.SNOW]), true), + new SpeciesFormChange(Species.CASTFORM, "sunny", "snowy", new SpeciesFormChangeWeatherTrigger(Abilities.FORECAST, [WeatherType.HAIL, WeatherType.SNOW]), true), + new SpeciesFormChange(Species.CASTFORM, "rainy", "snowy", new SpeciesFormChangeWeatherTrigger(Abilities.FORECAST, [WeatherType.HAIL, WeatherType.SNOW]), true), + new SpeciesFormChange(Species.CASTFORM, "sunny", "", new SpeciesFormChangeRevertWeatherFormTrigger(Abilities.FORECAST, [WeatherType.NONE, WeatherType.SANDSTORM, WeatherType.STRONG_WINDS, WeatherType.FOG]), true), + new SpeciesFormChange(Species.CASTFORM, "rainy", "", new SpeciesFormChangeRevertWeatherFormTrigger(Abilities.FORECAST, [WeatherType.NONE, WeatherType.SANDSTORM, WeatherType.STRONG_WINDS, WeatherType.FOG]), true), + new SpeciesFormChange(Species.CASTFORM, "snowy", "", new SpeciesFormChangeRevertWeatherFormTrigger(Abilities.FORECAST, [WeatherType.NONE, WeatherType.SANDSTORM, WeatherType.STRONG_WINDS, WeatherType.FOG]), true), + new SpeciesFormChange(Species.CASTFORM, "sunny", "", new SpeciesFormChangeActiveTrigger(), true), + new SpeciesFormChange(Species.CASTFORM, "rainy", "", new SpeciesFormChangeActiveTrigger(), true), + new SpeciesFormChange(Species.CASTFORM, "snowy", "", new SpeciesFormChangeActiveTrigger(), true), + ], }; export function initPokemonForms() { diff --git a/src/field/arena.ts b/src/field/arena.ts index 0443ef19544..7622b9a014f 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -20,7 +20,10 @@ import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import { TimeOfDay } from "#enums/time-of-day"; import { TrainerType } from "#enums/trainer-type"; -import { CommonAnimPhase } from "#app/phases/common-anim-phase.js"; +import { Abilities } from "#app/enums/abilities"; +import { SpeciesFormChangeRevertWeatherFormTrigger, SpeciesFormChangeWeatherTrigger } from "#app/data/pokemon-forms"; +import { CommonAnimPhase } from "#app/phases/common-anim-phase"; +import { ShowAbilityPhase } from "#app/phases/show-ability-phase"; export class Arena { public scene: BattleScene; @@ -331,6 +334,30 @@ export class Arena { return true; } + /** + * Function to trigger all weather based form changes + */ + triggerWeatherBasedFormChanges(): void { + this.scene.getField(true).forEach( p => { + if (p.hasAbility(Abilities.FORECAST) && p.species.speciesId === Species.CASTFORM) { + new ShowAbilityPhase(this.scene, p.getBattlerIndex()); + this.scene.triggerPokemonFormChange(p, SpeciesFormChangeWeatherTrigger); + } + }); + } + + /** + * Function to trigger all weather based form changes back into their normal forms + */ + triggerWeatherBasedFormChangesToNormal(): void { + this.scene.getField(true).forEach( p => { + if (p.hasAbility(Abilities.FORECAST, false, true) && p.species.speciesId === Species.CASTFORM) { + new ShowAbilityPhase(this.scene, p.getBattlerIndex()); + return this.scene.triggerPokemonFormChange(p, SpeciesFormChangeRevertWeatherFormTrigger); + } + }); + } + trySetTerrain(terrain: TerrainType, hasPokemonSource: boolean, ignoreAnim: boolean = false): boolean { if (this.terrain?.terrainType === (terrain || undefined)) { return false; diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 234afae3f40..5aea69bb726 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1140,7 +1140,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * effects which can affect whether an ability will be present or in effect, and both passive and * non-passive. This is the primary way to check whether a pokemon has a particular ability. * @param {Abilities} ability The ability to check for - * @param {boolean} canApply If false, it doesn't check whether the abiltiy is currently active + * @param {boolean} canApply If false, it doesn't check whether the ability is currently active * @param {boolean} ignoreOverride If true, it ignores ability changing effects * @returns {boolean} Whether the ability is present and active */ diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index 3e401925cea..05e041cd730 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -160,6 +160,8 @@ export class SwitchSummonPhase extends SummonPhase { this.lastPokemon?.resetSummonData(); this.scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); + // Reverts to weather-based forms when weather suppressors (Cloud Nine/Air Lock) are switched out + this.scene.arena.triggerWeatherBasedFormChanges(); } queuePostSummon(): void { diff --git a/src/phases/turn-end-phase.ts b/src/phases/turn-end-phase.ts index 62589e99b79..9f4de46b0fa 100644 --- a/src/phases/turn-end-phase.ts +++ b/src/phases/turn-end-phase.ts @@ -60,6 +60,7 @@ export class TurnEndPhase extends FieldPhase { if (this.scene.arena.weather && !this.scene.arena.weather.lapse()) { this.scene.arena.trySetWeather(WeatherType.NONE, false); + this.scene.arena.triggerWeatherBasedFormChangesToNormal(); } if (this.scene.arena.terrain && !this.scene.arena.terrain.lapse()) { diff --git a/src/test/abilities/forecast.test.ts b/src/test/abilities/forecast.test.ts new file mode 100644 index 00000000000..58f50c5a9a6 --- /dev/null +++ b/src/test/abilities/forecast.test.ts @@ -0,0 +1,347 @@ +import { Abilities } from "#app/enums/abilities.js"; +import GameManager from "#test/utils/gameManager"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { SPLASH_ONLY } from "#test/utils/testUtils"; +import { WeatherType } from "#app/enums/weather-type"; +import { BattlerIndex } from "#app/battle"; +import { QuietFormChangePhase } from "#app/phases/quiet-form-change-phase"; +import { DamagePhase } from "#app/phases/damage-phase"; +import { MovePhase } from "#app/phases/move-phase"; +import { PostSummonPhase } from "#app/phases/post-summon-phase"; +import { TurnEndPhase } from "#app/phases/turn-end-phase"; +import { VictoryPhase } from "#app/phases/victory-phase"; +import { allAbilities } from "#app/data/ability"; + +describe("Abilities - Forecast", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + const NORMAL_FORM = 0; + const SUNNY_FORM = 1; + const RAINY_FORM = 2; + const SNOWY_FORM = 3; + + /** + * Tests form changes based on weather changes + * @param {GameManager} game The game manager instance + * @param {WeatherType} weather The active weather to set + * @param form The expected form based on the active weather + * @param initialForm The initial form pre form change + */ + const testWeatherFormChange = async (game: GameManager, weather: WeatherType, form: number, initialForm?: number) => { + game.override.weather(weather).starterForms({[Species.CASTFORM]: initialForm}); + await game.startBattle([Species.CASTFORM]); + + game.move.select(Moves.SPLASH); + + expect(game.scene.getPlayerPokemon()?.formIndex).toBe(form); + }; + + /** + * Tests reverting to normal form when Cloud Nine/Air Lock is active on the field + * @param {GameManager} game The game manager instance + * @param {Abilities} ability The ability that is active on the field + */ + const testRevertFormAgainstAbility = async (game: GameManager, ability: Abilities) => { + game.override.starterForms({ [Species.CASTFORM]: SUNNY_FORM }).enemyAbility(ability); + await game.startBattle([Species.CASTFORM]); + + game.move.select(Moves.SPLASH); + + expect(game.scene.getPlayerPokemon()?.formIndex).toBe(NORMAL_FORM); + }; + + /** + * Tests transforming back to match the weather when Cloud Nine/Air Lock user is fainted + * @param {GameManager} game The game manager instance + * @param {Abilities} ability The ability that will go out of battle (faint) + */ + const testTransformAfterAbilityFaint = async (game: GameManager, ability: Abilities) => { + game.override.enemyAbility(ability).weather(WeatherType.SNOW).enemySpecies(Species.SHUCKLE); + await game.startBattle([Species.CASTFORM]); + const castform = game.scene.getPlayerPokemon(); + + expect(castform?.formIndex).toBe(NORMAL_FORM); + + game.move.select(Moves.TACKLE); + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + await game.phaseInterceptor.to(DamagePhase); + + await game.doKillOpponents(); + await game.phaseInterceptor.to(VictoryPhase); + + expect(castform?.formIndex).toBe(SNOWY_FORM); + }; + + /** + * Tests transforming back to match the weather when Cloud Nine/Air Lock user is switched out + * @param {GameManager} game The game manager instance + * @param {Abilities} ability The ability that will go out of battle (switched out) + */ + const testTransformAfterAbilitySwitchOut = async (game: GameManager, ability: Abilities) => { + game.override + .weather(WeatherType.SNOW) + .enemySpecies(Species.CASTFORM) + .enemyAbility(Abilities.FORECAST) + .ability(ability); + await game.startBattle([Species.PICHU, Species.PIKACHU]); + + const castform = game.scene.getEnemyPokemon(); + + // We mock the return value of the second Pokemon to be other than Air Lock/Cloud Nine + vi.spyOn(game.scene.getParty()[1]!, "getAbility").mockReturnValue(allAbilities[Abilities.BALL_FETCH]); + expect(game.scene.getParty()[1]?.hasAbility(Abilities.BALL_FETCH)); + + expect(castform?.formIndex).toBe(NORMAL_FORM); + + game.doSwitchPokemon(1); + await game.phaseInterceptor.to(MovePhase); + expect(castform?.formIndex).toBe(SNOWY_FORM); + }; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override.moveset([ Moves.SPLASH, Moves.RAIN_DANCE, Moves.SUNNY_DAY, Moves.TACKLE ]) + .enemyMoveset(SPLASH_ONLY) + .enemyAbility(Abilities.BALL_FETCH); + }); + + it("changes to Sunny Form during Harsh Sunlight", async () => { + await testWeatherFormChange(game, WeatherType.SUNNY, SUNNY_FORM); + }); + + it("changes to Sunny Form during Extreme Harsh Sunlight", async () => { + await testWeatherFormChange(game, WeatherType.HARSH_SUN, SUNNY_FORM); + }); + + it("changes to Rainy Form during Rain", async () => { + await testWeatherFormChange(game, WeatherType.RAIN, RAINY_FORM); + }); + + it("changes to Rainy Form during Heavy Rain", async () => { + await testWeatherFormChange(game, WeatherType.HEAVY_RAIN, RAINY_FORM); + }); + + it("changes to Snowy Form during Hail", async () => { + await testWeatherFormChange(game, WeatherType.HAIL, SNOWY_FORM); + }); + + it("changes to Snowy Form during Snow", async () => { + await testWeatherFormChange(game, WeatherType.SNOW, SNOWY_FORM); + }); + + it("reverts to Normal Form during Sandstorm", async () => { + await testWeatherFormChange(game, WeatherType.SANDSTORM, NORMAL_FORM, SUNNY_FORM); + }); + + it("reverts to Normal Form during Fog", async () => { + await testWeatherFormChange(game, WeatherType.FOG, NORMAL_FORM, SUNNY_FORM); + }); + + it("reverts to Normal Form during Strong Winds", async () => { + await testWeatherFormChange(game, WeatherType.STRONG_WINDS, NORMAL_FORM, SUNNY_FORM); + }); + + it("reverts to Normal Form during Clear weather", async () => { + await testWeatherFormChange(game, WeatherType.NONE, NORMAL_FORM, SUNNY_FORM); + }); + + it("reverts to Normal Form if a Pokémon on the field has Cloud Nine", async () => { + await testRevertFormAgainstAbility(game, Abilities.CLOUD_NINE); + }); + + it("reverts to Normal Form if a Pokémon on the field has Air Lock", async () => { + await testRevertFormAgainstAbility(game, Abilities.AIR_LOCK); + }); + + it("has no effect on Pokémon other than Castform", async () => { + game.override.enemyAbility(Abilities.FORECAST).enemySpecies(Species.SHUCKLE); + await game.startBattle([Species.CASTFORM]); + + game.move.select(Moves.RAIN_DANCE); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(game.scene.getPlayerPokemon()?.formIndex).toBe(RAINY_FORM); + expect(game.scene.getEnemyPokemon()?.formIndex).not.toBe(RAINY_FORM); + }); + + it("cannot be copied", async () => { + game.override.enemyAbility(Abilities.TRACE); + await game.startBattle([Species.CASTFORM]); + + game.move.select(Moves.SPLASH); + + expect(game.scene.getEnemyPokemon()?.hasAbility(Abilities.FORECAST)).toBe(false); + }); + + it("(Skill Swap) reverts to Normal Form when Castform loses Forecast, changes form to match the weather when it regains it", async () => { + game.override.moveset([Moves.SKILL_SWAP]).weather(WeatherType.RAIN); + await game.startBattle([Species.CASTFORM]); + const castform = game.scene.getPlayerPokemon(); + + expect(castform?.formIndex).toBe(RAINY_FORM); + + // First turn - loses Forecast + game.move.select(Moves.SKILL_SWAP); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(castform?.formIndex).toBe(NORMAL_FORM); + + // Second turn - regains Forecast + game.move.select(Moves.SKILL_SWAP); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(castform?.formIndex).toBe(RAINY_FORM); + }); + + it("(Worry Seed) reverts to Normal Form when Castform loses Forecast, changes form to match the weather when it regains it", async () => { + game.override.enemyMoveset(Array(4).fill(Moves.WORRY_SEED)).weather(WeatherType.RAIN); + await game.startBattle([Species.CASTFORM, Species.PIKACHU]); + const castform = game.scene.getPlayerPokemon(); + + expect(castform?.formIndex).toBe(RAINY_FORM); + + // First turn - loses Forecast + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to(TurnEndPhase); + + expect(castform?.formIndex).toBe(NORMAL_FORM); + + await game.toNextTurn(); + + // Second turn - switch out Castform, regains Forecast + game.doSwitchPokemon(1); + await game.toNextTurn(); + + // Third turn - switch in Castform + game.doSwitchPokemon(1); + await game.phaseInterceptor.to(MovePhase); + + expect(castform?.formIndex).toBe(RAINY_FORM); + }); + + it("reverts to Normal Form when active weather ends", async () => { + await game.startBattle([Species.CASTFORM]); + const castform = game.scene.getPlayerPokemon(); + + game.move.select(Moves.SUNNY_DAY); + await game.phaseInterceptor.to(TurnEndPhase); + + while (game.scene.arena.weather && game.scene.arena.weather.turnsLeft > 0) { + game.move.select(Moves.SPLASH); + expect(castform?.formIndex).toBe(SUNNY_FORM); + await game.toNextTurn(); + } + + expect(castform?.formIndex).toBe(NORMAL_FORM); + }); + + it("reverts to Normal Form when Forecast is suppressed, changes form to match the weather when it regains it", async () => { + game.override.enemyMoveset(Array(4).fill(Moves.GASTRO_ACID)).weather(WeatherType.RAIN); + await game.startBattle([Species.CASTFORM, Species.PIKACHU]); + const castform = game.scene.getPlayerPokemon(); + + expect(castform?.formIndex).toBe(RAINY_FORM); + + // First turn - Forecast is suppressed + game.move.select(Moves.SPLASH); + await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); + await game.move.forceHit(); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(castform?.summonData.abilitySuppressed).toBe(true); + expect(castform?.formIndex).toBe(NORMAL_FORM); + + await game.toNextTurn(); + + // Second turn - switch out Castform, regains Forecast + game.doSwitchPokemon(1); + await game.toNextTurn(); + + // Third turn - switch in Castform + game.doSwitchPokemon(1); + await game.phaseInterceptor.to(MovePhase); + + expect(castform?.summonData.abilitySuppressed).toBe(false); + expect(castform?.formIndex).toBe(RAINY_FORM); + }); + + it("if a Pokémon transforms into Castform, the Pokémon will remain in the same form as the target Castform, regardless of the weather", async () => { + game.override.enemyMoveset(Array(4).fill(Moves.TRANSFORM)); + await game.startBattle([Species.CASTFORM]); + + game.move.select(Moves.SUNNY_DAY); + await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); + + await game.phaseInterceptor.to(TurnEndPhase); + + expect(game.scene.getPlayerPokemon()?.formIndex).toBe(SUNNY_FORM); + expect(game.scene.getEnemyPokemon()?.formIndex).toBe(NORMAL_FORM); + }); + + it("does not change Castform's form until after Stealth Rock deals damage", async () => { + game.override.weather(WeatherType.RAIN).enemyMoveset(Array(4).fill(Moves.STEALTH_ROCK)); + await game.startBattle([Species.PIKACHU, Species.CASTFORM]); + + // First turn - set up stealth rock + game.move.select(Moves.SPLASH); + await game.toNextTurn(); + + // Second turn - switch in Castform, regains Forecast + game.doSwitchPokemon(1); + await game.phaseInterceptor.to(PostSummonPhase); + + const castform = game.scene.getPlayerPokemon(); + + // Damage phase should come first + await game.phaseInterceptor.to(DamagePhase); + expect(castform?.hp).toBeLessThan(castform?.getMaxHp() ?? 0); + + // Then change form + await game.phaseInterceptor.to(QuietFormChangePhase); + expect(castform?.formIndex).toBe(RAINY_FORM); + }); + + it("transforms to weather-based form when Pokemon with Air Lock is fainted", async () => { + await testTransformAfterAbilityFaint(game, Abilities.AIR_LOCK); + }); + + it("transforms to weather-based form when Pokemon with Cloud Nine is fainted", async () => { + await testTransformAfterAbilityFaint(game, Abilities.CLOUD_NINE); + }); + + it("transforms to weather-based form when Pokemon with Air Lock is switched out", async () => { + await testTransformAfterAbilitySwitchOut(game, Abilities.AIR_LOCK); + }); + + it("transforms to weather-based form when Pokemon with Cloud Nine is switched out", async () => { + await testTransformAfterAbilitySwitchOut(game, Abilities.CLOUD_NINE); + }); + + it("should be in Normal Form after the user is switched out", async () => { + game.override.weather(WeatherType.RAIN); + + await game.startBattle([Species.CASTFORM, Species.MAGIKARP]); + const castform = game.scene.getPlayerPokemon()!; + + expect(castform.formIndex).toBe(RAINY_FORM); + + game.doSwitchPokemon(1); + await game.toNextTurn(); + + expect(castform.formIndex).toBe(NORMAL_FORM); + }); +});