[Ability] Implement Forecast (#3534)

* initial forecast implementation

* updates

* bug fixes and add tests

* bug fixes

* update docs

* fix issues post-merge

* add show ability

* add support for simulated abilities

* add simulated conditions and fix tests

* fix simulated conditions
This commit is contained in:
Adrian T. 2024-08-25 17:23:09 +08:00 committed by GitHub
parent 19b7ebe94c
commit 03de6cfe36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 658 additions and 22 deletions

View File

@ -13,7 +13,7 @@ import { ArenaTagSide, ArenaTrapTag } from "./arena-tag";
import { Stat, getStatName } from "./pokemon-stat"; import { Stat, getStatName } from "./pokemon-stat";
import { BerryModifier, PokemonHeldItemModifier } from "../modifier/modifier"; import { BerryModifier, PokemonHeldItemModifier } from "../modifier/modifier";
import { TerrainType } from "./terrain"; import { TerrainType } from "./terrain";
import { SpeciesFormChangeManualTrigger } from "./pokemon-forms"; import { SpeciesFormChangeManualTrigger, SpeciesFormChangeRevertWeatherFormTrigger, SpeciesFormChangeWeatherTrigger } from "./pokemon-forms";
import i18next from "i18next"; import i18next from "i18next";
import { Localizable } from "#app/interfaces/locales.js"; import { Localizable } from "#app/interfaces/locales.js";
import { Command } from "../ui/command-ui-handler"; 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 { BattlerTagType } from "#enums/battler-tag-type";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import { MovePhase } from "#app/phases/move-phase.js"; import { MovePhase } from "#app/phases/move-phase";
import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase.js"; import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase";
import { ShowAbilityPhase } from "#app/phases/show-ability-phase.js"; import { ShowAbilityPhase } from "#app/phases/show-ability-phase";
import { StatChangePhase } from "#app/phases/stat-change-phase.js"; import { StatChangePhase } from "#app/phases/stat-change-phase";
import BattleScene from "#app/battle-scene";
export class Ability implements Localizable { export class Ability implements Localizable {
public id: Abilities; 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 { export class PreSwitchOutAbAttr extends AbAttr {
constructor() { constructor() {
super(true); 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 { export class PostWeatherChangeAddBattlerTagAttr extends PostWeatherChangeAbAttr {
private tagType: BattlerTagType; private tagType: BattlerTagType;
private turnCount: integer; 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 * 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 const allAbilities = [ new Ability(Abilities.NONE, 3) ];
export function initAbilities() { export function initAbilities() {
@ -4605,7 +4758,10 @@ export function initAbilities() {
.ignorable(), .ignorable(),
new Ability(Abilities.CLOUD_NINE, 3) new Ability(Abilities.CLOUD_NINE, 3)
.attr(SuppressWeatherEffectAbAttr, true) .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) new Ability(Abilities.COMPOUND_EYES, 3)
.attr(BattleStatMultiplierAbAttr, BattleStat.ACC, 1.3), .attr(BattleStatMultiplierAbAttr, BattleStat.ACC, 1.3),
new Ability(Abilities.INSOMNIA, 3) new Ability(Abilities.INSOMNIA, 3)
@ -4750,7 +4906,8 @@ export function initAbilities() {
new Ability(Abilities.FORECAST, 3) new Ability(Abilities.FORECAST, 3)
.attr(UncopiableAbilityAbAttr) .attr(UncopiableAbilityAbAttr)
.attr(NoFusionAbilityAbAttr) .attr(NoFusionAbilityAbAttr)
.unimplemented(), .attr(PostSummonFormChangeByWeatherAbAttr, Abilities.FORECAST)
.attr(PostWeatherChangeFormChangeAbAttr, Abilities.FORECAST),
new Ability(Abilities.STICKY_HOLD, 3) new Ability(Abilities.STICKY_HOLD, 3)
.attr(BlockItemTheftAbAttr) .attr(BlockItemTheftAbAttr)
.bypassFaint() .bypassFaint()
@ -4800,7 +4957,10 @@ export function initAbilities() {
.ignorable(), .ignorable(),
new Ability(Abilities.AIR_LOCK, 3) new Ability(Abilities.AIR_LOCK, 3)
.attr(SuppressWeatherEffectAbAttr, true) .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) new Ability(Abilities.TANGLED_FEET, 4)
.conditionalAttr(pokemon => !!pokemon.getTag(BattlerTagType.CONFUSED), BattleStatMultiplierAbAttr, BattleStat.EVA, 2) .conditionalAttr(pokemon => !!pokemon.getTag(BattlerTagType.CONFUSED), BattleStatMultiplierAbAttr, BattleStat.EVA, 2)
.ignorable(), .ignorable(),

View File

@ -26,16 +26,17 @@ import { BattlerTagType } from "#enums/battler-tag-type";
import { Biome } from "#enums/biome"; import { Biome } from "#enums/biome";
import { Moves } from "#enums/moves"; import { Moves } from "#enums/moves";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import { MoveUsedEvent } from "#app/events/battle-scene.js"; import { MoveUsedEvent } from "#app/events/battle-scene";
import { PartyStatusCurePhase } from "#app/phases/party-status-cure-phase.js"; import { PartyStatusCurePhase } from "#app/phases/party-status-cure-phase";
import { BattleEndPhase } from "#app/phases/battle-end-phase.js"; import { BattleEndPhase } from "#app/phases/battle-end-phase";
import { MoveEndPhase } from "#app/phases/move-end-phase.js"; import { MoveEndPhase } from "#app/phases/move-end-phase";
import { MovePhase } from "#app/phases/move-phase.js"; import { MovePhase } from "#app/phases/move-phase";
import { NewBattlePhase } from "#app/phases/new-battle-phase.js"; import { NewBattlePhase } from "#app/phases/new-battle-phase";
import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase.js"; import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase";
import { StatChangePhase } from "#app/phases/stat-change-phase.js"; import { StatChangePhase } from "#app/phases/stat-change-phase";
import { SwitchPhase } from "#app/phases/switch-phase.js"; import { SwitchPhase } from "#app/phases/switch-phase";
import { SwitchSummonPhase } from "#app/phases/switch-summon-phase.js"; import { SwitchSummonPhase } from "#app/phases/switch-summon-phase";
import { SpeciesFormChangeRevertWeatherFormTrigger } from "./pokemon-forms";
export enum MoveCategory { export enum MoveCategory {
PHYSICAL, PHYSICAL,
@ -5739,7 +5740,10 @@ export class AbilityChangeAttr extends MoveEffectAttr {
return false; 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})); 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; target.summonData.ability = tempAbilityId;
user.scene.queueMessage(i18next.t("moveTriggers:swappedAbilitiesWithTarget", {pokemonName: getPokemonNameWithAffix(user)})); 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; return true;
} }
@ -5850,6 +5858,7 @@ export class SuppressAbilitiesAttr extends MoveEffectAttr {
} }
target.summonData.abilitySuppressed = true; target.summonData.abilitySuppressed = true;
target.scene.arena.triggerWeatherBasedFormChangesToNormal();
target.scene.queueMessage(i18next.t("moveTriggers:suppressAbilities", {pokemonName: getPokemonNameWithAffix(target)})); target.scene.queueMessage(i18next.t("moveTriggers:suppressAbilities", {pokemonName: getPokemonNameWithAffix(target)}));

View File

@ -10,6 +10,7 @@ import { Species } from "#enums/species";
import { TimeOfDay } from "#enums/time-of-day"; import { TimeOfDay } from "#enums/time-of-day";
import { getPokemonNameWithAffix } from "#app/messages.js"; import { getPokemonNameWithAffix } from "#app/messages.js";
import i18next from "i18next"; import i18next from "i18next";
import { WeatherType } from "./weather";
export enum FormChangeItem { export enum FormChangeItem {
NONE, 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 { export function getSpeciesFormChangeMessage(pokemon: Pokemon, formChange: SpeciesFormChange, preName: string): string {
const isMega = formChange.formKey.indexOf(SpeciesFormKey.MEGA) > -1; const isMega = formChange.formKey.indexOf(SpeciesFormKey.MEGA) > -1;
const isGmax = formChange.formKey.indexOf(SpeciesFormKey.GIGANTAMAX) > -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, "gorging", "", new SpeciesFormChangeManualTrigger, true),
new SpeciesFormChange(Species.CRAMORANT, "gulping", "", new SpeciesFormChangeActiveTrigger(false), true), new SpeciesFormChange(Species.CRAMORANT, "gulping", "", new SpeciesFormChangeActiveTrigger(false), true),
new SpeciesFormChange(Species.CRAMORANT, "gorging", "", 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() { export function initPokemonForms() {

View File

@ -20,7 +20,10 @@ import { Moves } from "#enums/moves";
import { Species } from "#enums/species"; import { Species } from "#enums/species";
import { TimeOfDay } from "#enums/time-of-day"; import { TimeOfDay } from "#enums/time-of-day";
import { TrainerType } from "#enums/trainer-type"; 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 { export class Arena {
public scene: BattleScene; public scene: BattleScene;
@ -331,6 +334,30 @@ export class Arena {
return true; 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 { trySetTerrain(terrain: TerrainType, hasPokemonSource: boolean, ignoreAnim: boolean = false): boolean {
if (this.terrain?.terrainType === (terrain || undefined)) { if (this.terrain?.terrainType === (terrain || undefined)) {
return false; return false;

View File

@ -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 * 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. * 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 {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 * @param {boolean} ignoreOverride If true, it ignores ability changing effects
* @returns {boolean} Whether the ability is present and active * @returns {boolean} Whether the ability is present and active
*/ */

View File

@ -160,6 +160,8 @@ export class SwitchSummonPhase extends SummonPhase {
this.lastPokemon?.resetSummonData(); this.lastPokemon?.resetSummonData();
this.scene.triggerPokemonFormChange(pokemon, SpeciesFormChangeActiveTrigger, true); 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 { queuePostSummon(): void {

View File

@ -60,6 +60,7 @@ export class TurnEndPhase extends FieldPhase {
if (this.scene.arena.weather && !this.scene.arena.weather.lapse()) { if (this.scene.arena.weather && !this.scene.arena.weather.lapse()) {
this.scene.arena.trySetWeather(WeatherType.NONE, false); this.scene.arena.trySetWeather(WeatherType.NONE, false);
this.scene.arena.triggerWeatherBasedFormChangesToNormal();
} }
if (this.scene.arena.terrain && !this.scene.arena.terrain.lapse()) { if (this.scene.arena.terrain && !this.scene.arena.terrain.lapse()) {

View File

@ -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);
});
});