diff --git a/public/locales b/public/locales index 3cf6d553541..fc4a1effd51 160000 --- a/public/locales +++ b/public/locales @@ -1 +1 @@ -Subproject commit 3cf6d553541d79ba165387bc73fb06544d00f1f9 +Subproject commit fc4a1effd5170def3c8314208a52cd0d8e6913ef diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 3cbf4d7b422..3c561206abe 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -323,6 +323,7 @@ export default class BattleScene extends SceneBase { this.conditionalQueue = []; this.phaseQueuePrependSpliceIndex = -1; this.nextCommandPhaseQueue = []; + this.eventManager = new TimedEventManager(); this.updateGameInfo(); } @@ -378,7 +379,6 @@ export default class BattleScene extends SceneBase { this.fieldSpritePipeline = new FieldSpritePipeline(this.game); (this.renderer as Phaser.Renderer.WebGL.WebGLRenderer).pipelines.add("FieldSprite", this.fieldSpritePipeline); - this.eventManager = new TimedEventManager(); this.launchBattle(); } diff --git a/src/data/move.ts b/src/data/move.ts index 401c98baf8f..ab1219c2906 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -1049,31 +1049,80 @@ export enum MoveEffectTrigger { POST_TARGET, } +interface MoveEffectAttrOptions { + /** + * Defines when this effect should trigger in the move's effect order + * @see {@linkcode MoveEffectPhase} + */ + trigger?: MoveEffectTrigger; + /** Should this effect only apply on the first hit? */ + firstHitOnly?: boolean; + /** Should this effect only apply on the last hit? */ + lastHitOnly?: boolean; + /** Should this effect only apply on the first target hit? */ + firstTargetOnly?: boolean; + /** Overrides the secondary effect chance for this attr if set. */ + effectChanceOverride?: number; +} + /** Base class defining all Move Effect Attributes * @extends MoveAttr * @see {@linkcode apply} */ export class MoveEffectAttr extends MoveAttr { - /** Defines when this effect should trigger in the move's effect order - * @see {@linkcode phases.MoveEffectPhase.start} + /** + * A container for this attribute's optional parameters + * @see {@linkcode MoveEffectAttrOptions} for supported params. */ - public trigger: MoveEffectTrigger; - /** Should this effect only apply on the first hit? */ - public firstHitOnly: boolean; - /** Should this effect only apply on the last hit? */ - public lastHitOnly: boolean; - /** Should this effect only apply on the first target hit? */ - public firstTargetOnly: boolean; - /** Overrides the secondary effect chance for this attr if set. */ - public effectChanceOverride?: number; + protected options?: MoveEffectAttrOptions; - constructor(selfTarget?: boolean, trigger?: MoveEffectTrigger, firstHitOnly: boolean = false, lastHitOnly: boolean = false, firstTargetOnly: boolean = false, effectChanceOverride?: number) { + constructor(selfTarget?: boolean, options?: MoveEffectAttrOptions) { super(selfTarget); - this.trigger = trigger ?? MoveEffectTrigger.POST_APPLY; - this.firstHitOnly = firstHitOnly; - this.lastHitOnly = lastHitOnly; - this.firstTargetOnly = firstTargetOnly; - this.effectChanceOverride = effectChanceOverride; + this.options = options; + } + + /** + * Defines when this effect should trigger in the move's effect order. + * @default MoveEffectTrigger.POST_APPLY + * @see {@linkcode MoveEffectTrigger} + */ + public get trigger () { + return this.options?.trigger ?? MoveEffectTrigger.POST_APPLY; + } + + /** + * `true` if this effect should only trigger on the first hit of + * multi-hit moves. + * @default false + */ + public get firstHitOnly () { + return this.options?.firstHitOnly ?? false; + } + + /** + * `true` if this effect should only trigger on the last hit of + * multi-hit moves. + * @default false + */ + public get lastHitOnly () { + return this.options?.lastHitOnly ?? false; + } + + /** + * `true` if this effect should apply only upon hitting a target + * for the first time when targeting multiple {@linkcode Pokemon}. + * @default false + */ + public get firstTargetOnly () { + return this.options?.firstTargetOnly ?? false; + } + + /** + * If defined, overrides the move's base chance for this + * secondary effect to trigger. + */ + public get effectChanceOverride () { + return this.options?.effectChanceOverride; } /** @@ -1398,7 +1447,7 @@ export class RecoilAttr extends MoveEffectAttr { private unblockable: boolean; constructor(useHp: boolean = false, damageRatio: number = 0.25, unblockable: boolean = false) { - super(true, MoveEffectTrigger.POST_APPLY, false, true); + super(true, { lastHitOnly: true }); this.useHp = useHp; this.damageRatio = damageRatio; @@ -1456,7 +1505,7 @@ export class RecoilAttr extends MoveEffectAttr { **/ export class SacrificialAttr extends MoveEffectAttr { constructor() { - super(true, MoveEffectTrigger.POST_TARGET); + super(true, { trigger: MoveEffectTrigger.POST_TARGET }); } /** @@ -1489,7 +1538,7 @@ export class SacrificialAttr extends MoveEffectAttr { **/ export class SacrificialAttrOnHit extends MoveEffectAttr { constructor() { - super(true, MoveEffectTrigger.HIT); + super(true, { trigger: MoveEffectTrigger.HIT }); } /** @@ -1528,7 +1577,7 @@ export class SacrificialAttrOnHit extends MoveEffectAttr { */ export class HalfSacrificialAttr extends MoveEffectAttr { constructor() { - super(true, MoveEffectTrigger.POST_TARGET); + super(true, { trigger: MoveEffectTrigger.POST_TARGET }); } /** @@ -1932,7 +1981,7 @@ export class HitHealAttr extends MoveEffectAttr { private healStat: EffectiveStat | null; constructor(healRatio?: number | null, healStat?: EffectiveStat) { - super(true, MoveEffectTrigger.HIT); + super(true, { trigger: MoveEffectTrigger.HIT }); this.healRatio = healRatio ?? 0.5; this.healStat = healStat ?? null; @@ -2141,7 +2190,7 @@ export class StatusEffectAttr extends MoveEffectAttr { public overrideStatus: boolean = false; constructor(effect: StatusEffect, selfTarget?: boolean, turnsRemaining?: number, overrideStatus: boolean = false) { - super(selfTarget, MoveEffectTrigger.HIT); + super(selfTarget, { trigger: MoveEffectTrigger.HIT }); this.effect = effect; this.turnsRemaining = turnsRemaining; @@ -2214,7 +2263,7 @@ export class MultiStatusEffectAttr extends StatusEffectAttr { export class PsychoShiftEffectAttr extends MoveEffectAttr { constructor() { - super(false, MoveEffectTrigger.HIT); + super(false, { trigger: MoveEffectTrigger.HIT }); } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { @@ -2251,7 +2300,7 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr { private chance: number; constructor(chance: number) { - super(false, MoveEffectTrigger.HIT); + super(false, { trigger: MoveEffectTrigger.HIT }); this.chance = chance; } @@ -2312,7 +2361,7 @@ export class RemoveHeldItemAttr extends MoveEffectAttr { private berriesOnly: boolean; constructor(berriesOnly: boolean) { - super(false, MoveEffectTrigger.HIT); + super(false, { trigger: MoveEffectTrigger.HIT }); this.berriesOnly = berriesOnly; } @@ -2386,7 +2435,7 @@ export class RemoveHeldItemAttr extends MoveEffectAttr { export class EatBerryAttr extends MoveEffectAttr { protected chosenBerry: BerryModifier | undefined; constructor() { - super(true, MoveEffectTrigger.HIT); + super(true, { trigger: MoveEffectTrigger.HIT }); } /** * Causes the target to eat a berry. @@ -2489,7 +2538,7 @@ export class HealStatusEffectAttr extends MoveEffectAttr { * @param ...effects - List of status effects to cure */ constructor(selfTarget: boolean, ...effects: StatusEffect[]) { - super(selfTarget, MoveEffectTrigger.POST_APPLY, false, true); + super(selfTarget, { lastHitOnly: true }); this.effects = effects; } @@ -2819,35 +2868,67 @@ export class AwaitCombinedPledgeAttr extends OverrideMoveEffectAttr { } } +/** + * Set of optional parameters that may be applied to stat stage changing effects + * @extends MoveEffectAttrOptions + * @see {@linkcode StatStageChangeAttr} + */ +interface StatStageChangeAttrOptions extends MoveEffectAttrOptions { + /** If defined, needs to be met in order for the stat change to apply */ + condition?: MoveConditionFunc, + /** `true` to display a message */ + showMessage?: boolean +} + /** * Attribute used for moves that change stat stages * * @param stats {@linkcode BattleStat} Array of stat(s) to change * @param stages How many stages to change the stat(s) by, [-6, 6] * @param selfTarget `true` if the move is self-targetting - * @param condition {@linkcode MoveConditionFunc} Optional condition to be checked in order to apply the changes - * @param showMessage `true` to display a message; default `true` - * @param firstHitOnly `true` if only the first hit of a multi hit move should cause a stat stage change; default `false` - * @param moveEffectTrigger {@linkcode MoveEffectTrigger} When the stat change should trigger; default {@linkcode MoveEffectTrigger.HIT} - * @param firstTargetOnly `true` if a move that hits multiple pokemon should only trigger the stat change if it hits at least one pokemon, rather than once per hit pokemon; default `false` - * @param lastHitOnly `true` if the effect should only apply after the last hit of a multi hit move; default `false` - * @param effectChanceOverride Will override the move's normal secondary effect chance if specified + * @param options {@linkcode StatStageChangeAttrOptions} Container for any optional parameters for this attribute. * * @extends MoveEffectAttr * @see {@linkcode apply} */ export class StatStageChangeAttr extends MoveEffectAttr { public stats: BattleStat[]; - public stages: integer; - private condition?: MoveConditionFunc | null; - private showMessage: boolean; + public stages: number; + /** + * Container for optional parameters to this attribute. + * @see {@linkcode StatStageChangeAttrOptions} for available optional params + */ + protected override options?: StatStageChangeAttrOptions; - constructor(stats: BattleStat[], stages: integer, selfTarget?: boolean, condition?: MoveConditionFunc | null, showMessage: boolean = true, firstHitOnly: boolean = false, moveEffectTrigger: MoveEffectTrigger = MoveEffectTrigger.HIT, firstTargetOnly: boolean = false, lastHitOnly: boolean = false, effectChanceOverride?: number) { - super(selfTarget, moveEffectTrigger, firstHitOnly, lastHitOnly, firstTargetOnly, effectChanceOverride); + constructor(stats: BattleStat[], stages: number, selfTarget?: boolean, options?: StatStageChangeAttrOptions) { + super(selfTarget, options); this.stats = stats; this.stages = stages; - this.condition = condition; - this.showMessage = showMessage; + this.options = options; + } + + /** + * The condition required for the stat stage change to apply. + * Defaults to `null` (i.e. no condition required). + */ + private get condition () { + return this.options?.condition ?? null; + } + + /** + * `true` to display a message for the stat change. + * @default true + */ + private get showMessage () { + return this.options?.showMessage ?? true; + } + + /** + * Indicates when the stat change should trigger + * @default MoveEffectTrigger.HIT + */ + public override get trigger () { + return this.options?.trigger ?? MoveEffectTrigger.HIT; } /** @@ -2932,20 +3013,6 @@ export class SecretPowerAttr extends MoveEffectAttr { super(false); } - /** - * Used to determine if the move should apply a secondary effect based on Secret Power's 30% chance - * @returns `true` if the move's secondary effect should apply - */ - override canApply(user: Pokemon, target: Pokemon, move: Move, args?: any[]): boolean { - this.effectChanceOverride = move.chance; - const moveChance = this.getMoveChance(user, target, move, this.selfTarget); - if (moveChance < 0 || moveChance === 100 || user.randSeedInt(100) < moveChance) { - return true; - } else { - return false; - } - } - /** * Used to apply the secondary effect to the target Pokemon * @returns `true` if a secondary effect is successfully applied @@ -2962,8 +3029,6 @@ export class SecretPowerAttr extends MoveEffectAttr { const biome = user.scene.arena.biomeType; secondaryEffect = this.determineBiomeEffect(biome); } - // effectChanceOverride used in the application of the actual secondary effect - secondaryEffect.effectChanceOverride = 100; return secondaryEffect.apply(user, target, move, []); } @@ -3139,7 +3204,7 @@ export class CutHpStatStageBoostAttr extends StatStageChangeAttr { private messageCallback: ((user: Pokemon) => void) | undefined; constructor(stat: BattleStat[], levels: integer, cutRatio: integer, messageCallback?: ((user: Pokemon) => void) | undefined) { - super(stat, levels, true, null, true); + super(stat, levels, true); this.cutRatio = cutRatio; this.messageCallback = messageCallback; @@ -4889,7 +4954,7 @@ export class BypassRedirectAttr extends MoveAttr { export class FrenzyAttr extends MoveEffectAttr { constructor() { - super(true, MoveEffectTrigger.HIT, false, true); + super(true, { trigger: MoveEffectTrigger.HIT, lastHitOnly: true }); } canApply(user: Pokemon, target: Pokemon, move: Move, args: any[]) { @@ -4962,7 +5027,7 @@ export class AddBattlerTagAttr extends MoveEffectAttr { private failOnOverlap: boolean; constructor(tagType: BattlerTagType, selfTarget: boolean = false, failOnOverlap: boolean = false, turnCountMin: integer = 0, turnCountMax?: integer, lastHitOnly: boolean = false, cancelOnFail: boolean = false) { - super(selfTarget, MoveEffectTrigger.POST_APPLY, false, lastHitOnly); + super(selfTarget, { lastHitOnly: lastHitOnly }); this.tagType = tagType; this.turnCountMin = turnCountMin; @@ -5397,7 +5462,7 @@ export class AddArenaTagAttr extends MoveEffectAttr { public selfSideTarget: boolean; constructor(tagType: ArenaTagType, turnCount?: integer | null, failOnOverlap: boolean = false, selfSideTarget: boolean = false) { - super(true, MoveEffectTrigger.POST_APPLY); + super(true); this.tagType = tagType; this.turnCount = turnCount!; // TODO: is the bang correct? @@ -5435,7 +5500,7 @@ export class RemoveArenaTagsAttr extends MoveEffectAttr { public selfSideTarget: boolean; constructor(tagTypes: ArenaTagType[], selfSideTarget: boolean) { - super(true, MoveEffectTrigger.POST_APPLY); + super(true); this.tagTypes = tagTypes; this.selfSideTarget = selfSideTarget; @@ -5501,7 +5566,7 @@ export class RemoveArenaTrapAttr extends MoveEffectAttr { private targetBothSides: boolean; constructor(targetBothSides: boolean = false) { - super(true, MoveEffectTrigger.PRE_APPLY); + super(true, { trigger: MoveEffectTrigger.PRE_APPLY }); this.targetBothSides = targetBothSides; } @@ -5537,7 +5602,7 @@ export class RemoveScreensAttr extends MoveEffectAttr { private targetBothSides: boolean; constructor(targetBothSides: boolean = false) { - super(true, MoveEffectTrigger.PRE_APPLY); + super(true, { trigger: MoveEffectTrigger.PRE_APPLY }); this.targetBothSides = targetBothSides; } @@ -5575,7 +5640,7 @@ export class SwapArenaTagsAttr extends MoveEffectAttr { constructor(SwapTags: ArenaTagType[]) { - super(true, MoveEffectTrigger.POST_APPLY); + super(true); this.SwapTags = SwapTags; } @@ -5701,7 +5766,7 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { private selfSwitch: boolean = false, private switchType: SwitchType = SwitchType.SWITCH ) { - super(false, MoveEffectTrigger.POST_APPLY, false, true); + super(false, { lastHitOnly: true }); } isBatonPass() { @@ -5750,6 +5815,11 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { return false; } + // Don't allow wild mons to flee with U-turn et al + if (this.selfSwitch && !user.isPlayer() && move.category !== MoveCategory.STATUS) { + return false; + } + if (switchOutTarget.hp > 0) { switchOutTarget.leaveField(false); user.scene.queueMessage(i18next.t("moveTriggers:fled", { pokemonName: getPokemonNameWithAffix(switchOutTarget) }), null, true, 500); @@ -5834,7 +5904,6 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { } } - export class ChillyReceptionAttr extends ForceSwitchOutAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { user.scene.arena.trySetWeather(WeatherType.SNOW, true); @@ -5852,7 +5921,7 @@ export class RemoveTypeAttr extends MoveEffectAttr { private messageCallback: ((user: Pokemon) => void) | undefined; constructor(removedType: Type, messageCallback?: (user: Pokemon) => void) { - super(true, MoveEffectTrigger.POST_TARGET); + super(true, { trigger: MoveEffectTrigger.POST_TARGET }); this.removedType = removedType; this.messageCallback = messageCallback; @@ -5921,22 +5990,114 @@ export class CopyBiomeTypeAttr extends MoveEffectAttr { return false; } - const biomeType = user.scene.arena.getTypeForBiome(); + const terrainType = user.scene.arena.getTerrainType(); + let typeChange: Type; + if (terrainType !== TerrainType.NONE) { + typeChange = this.getTypeForTerrain(user.scene.arena.getTerrainType()); + } else { + typeChange = this.getTypeForBiome(user.scene.arena.biomeType); + } - user.summonData.types = [ biomeType ]; + user.summonData.types = [ typeChange ]; user.updateInfo(); - user.scene.queueMessage(i18next.t("moveTriggers:transformedIntoType", { pokemonName: getPokemonNameWithAffix(user), typeName: i18next.t(`pokemonInfo:Type.${Type[biomeType]}`) })); + user.scene.queueMessage(i18next.t("moveTriggers:transformedIntoType", { pokemonName: getPokemonNameWithAffix(user), typeName: i18next.t(`pokemonInfo:Type.${Type[typeChange]}`) })); return true; } + + /** + * Retrieves a type from the current terrain + * @param terrainType {@linkcode TerrainType} + * @returns {@linkcode Type} + */ + private getTypeForTerrain(terrainType: TerrainType): Type { + switch (terrainType) { + case TerrainType.ELECTRIC: + return Type.ELECTRIC; + case TerrainType.MISTY: + return Type.FAIRY; + case TerrainType.GRASSY: + return Type.GRASS; + case TerrainType.PSYCHIC: + return Type.PSYCHIC; + case TerrainType.NONE: + default: + return Type.UNKNOWN; + } + } + + /** + * Retrieves a type from the current biome + * @param biomeType {@linkcode Biome} + * @returns {@linkcode Type} + */ + private getTypeForBiome(biomeType: Biome): Type { + switch (biomeType) { + case Biome.TOWN: + case Biome.PLAINS: + case Biome.METROPOLIS: + return Type.NORMAL; + case Biome.GRASS: + case Biome.TALL_GRASS: + return Type.GRASS; + case Biome.FOREST: + case Biome.JUNGLE: + return Type.BUG; + case Biome.SLUM: + case Biome.SWAMP: + return Type.POISON; + case Biome.SEA: + case Biome.BEACH: + case Biome.LAKE: + case Biome.SEABED: + return Type.WATER; + case Biome.MOUNTAIN: + return Type.FLYING; + case Biome.BADLANDS: + return Type.GROUND; + case Biome.CAVE: + case Biome.DESERT: + return Type.ROCK; + case Biome.ICE_CAVE: + case Biome.SNOWY_FOREST: + return Type.ICE; + case Biome.MEADOW: + case Biome.FAIRY_CAVE: + case Biome.ISLAND: + return Type.FAIRY; + case Biome.POWER_PLANT: + return Type.ELECTRIC; + case Biome.VOLCANO: + return Type.FIRE; + case Biome.GRAVEYARD: + case Biome.TEMPLE: + return Type.GHOST; + case Biome.DOJO: + case Biome.CONSTRUCTION_SITE: + return Type.FIGHTING; + case Biome.FACTORY: + case Biome.LABORATORY: + return Type.STEEL; + case Biome.RUINS: + case Biome.SPACE: + return Type.PSYCHIC; + case Biome.WASTELAND: + case Biome.END: + return Type.DRAGON; + case Biome.ABYSS: + return Type.DARK; + default: + return Type.UNKNOWN; + } + } } export class ChangeTypeAttr extends MoveEffectAttr { private type: Type; constructor(type: Type) { - super(false, MoveEffectTrigger.HIT); + super(false, { trigger: MoveEffectTrigger.HIT }); this.type = type; } @@ -5959,7 +6120,7 @@ export class AddTypeAttr extends MoveEffectAttr { private type: Type; constructor(type: Type) { - super(false, MoveEffectTrigger.HIT); + super(false, { trigger: MoveEffectTrigger.HIT }); this.type = type; } @@ -6486,7 +6647,7 @@ export class AbilityChangeAttr extends MoveEffectAttr { public ability: Abilities; constructor(ability: Abilities, selfTarget?: boolean) { - super(selfTarget, MoveEffectTrigger.HIT); + super(selfTarget, { trigger: MoveEffectTrigger.HIT }); this.ability = ability; } @@ -6515,7 +6676,7 @@ export class AbilityCopyAttr extends MoveEffectAttr { public copyToPartner: boolean; constructor(copyToPartner: boolean = false) { - super(false, MoveEffectTrigger.HIT); + super(false, { trigger: MoveEffectTrigger.HIT }); this.copyToPartner = copyToPartner; } @@ -6554,7 +6715,7 @@ export class AbilityGiveAttr extends MoveEffectAttr { public copyToPartner: boolean; constructor() { - super(false, MoveEffectTrigger.HIT); + super(false, { trigger: MoveEffectTrigger.HIT }); } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { @@ -6866,7 +7027,7 @@ export class DiscourageFrequentUseAttr extends MoveAttr { export class MoneyAttr extends MoveEffectAttr { constructor() { - super(true, MoveEffectTrigger.HIT, true); + super(true, { trigger: MoveEffectTrigger.HIT, firstHitOnly: true }); } apply(user: Pokemon, target: Pokemon, move: Move): boolean { @@ -6883,7 +7044,7 @@ export class MoneyAttr extends MoveEffectAttr { */ export class DestinyBondAttr extends MoveEffectAttr { constructor() { - super(true, MoveEffectTrigger.PRE_APPLY); + super(true, { trigger: MoveEffectTrigger.PRE_APPLY }); } /** @@ -6933,7 +7094,7 @@ export class StatusIfBoostedAttr extends MoveEffectAttr { public effect: StatusEffect; constructor(effect: StatusEffect) { - super(true, MoveEffectTrigger.HIT); + super(true, { trigger: MoveEffectTrigger.HIT }); this.effect = effect; } @@ -7058,6 +7219,11 @@ const targetSleptOrComatoseCondition: MoveConditionFunc = (user: Pokemon, target const failIfLastCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => user.scene.phaseQueue.find(phase => phase instanceof MovePhase) !== undefined; +const failIfLastInPartyCondition: MoveConditionFunc = (user: Pokemon, target: Pokemon, move: Move) => { + const party: Pokemon[] = user.isPlayer() ? user.scene.getParty() : user.scene.getEnemyParty(); + return party.some(pokemon => pokemon.isActive() && !pokemon.isOnField()); +}; + export type MoveAttrFilter = (attr: MoveAttr) => boolean; function applyMoveAttrsInternal(attrFilter: MoveAttrFilter, user: Pokemon | null, target: Pokemon | null, move: Move, args: any[]): Promise { @@ -7967,6 +8133,7 @@ export function initMoves() { .attr(StatusEffectAttr, StatusEffect.PARALYSIS), new SelfStatusMove(Moves.BATON_PASS, Type.NORMAL, -1, 40, -1, 0, 2) .attr(ForceSwitchOutAttr, true, SwitchType.BATON_PASS) + .condition(failIfLastInPartyCondition) .hidesUser(), new StatusMove(Moves.ENCORE, Type.NORMAL, 100, 5, -1, 0, 2) .attr(AddBattlerTagAttr, BattlerTagType.ENCORE, false, true) @@ -8985,7 +9152,7 @@ export function initMoves() { // If any fielded pokémon is grass-type and grounded. return [ ...user.scene.getEnemyParty(), ...user.scene.getParty() ].some((poke) => poke.isOfType(Type.GRASS) && poke.isGrounded()); }) - .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, false, (user, target, move) => target.isOfType(Type.GRASS) && target.isGrounded()), + .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, false, { condition: (user, target, move) => target.isOfType(Type.GRASS) && target.isGrounded() }), new StatusMove(Moves.STICKY_WEB, Type.BUG, -1, 20, -1, 0, 6) .attr(AddArenaTrapTagAttr, ArenaTagType.STICKY_WEB) .target(MoveTarget.ENEMY_SIDE), @@ -9022,7 +9189,7 @@ export function initMoves() { .soundBased() .target(MoveTarget.ALL_NEAR_ENEMIES), new StatusMove(Moves.PARTING_SHOT, Type.DARK, 100, 20, -1, 0, 6) - .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, false, null, true, true, MoveEffectTrigger.PRE_APPLY) + .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, false, { trigger: MoveEffectTrigger.PRE_APPLY }) .attr(ForceSwitchOutAttr, true) .soundBased(), new StatusMove(Moves.TOPSY_TURVY, Type.DARK, -1, 20, -1, 0, 6) @@ -9037,7 +9204,7 @@ export function initMoves() { .condition(failIfLastCondition), new StatusMove(Moves.FLOWER_SHIELD, Type.FAIRY, -1, 10, -1, 0, 6) .target(MoveTarget.ALL) - .attr(StatStageChangeAttr, [ Stat.DEF ], 1, false, (user, target, move) => target.getTypes().includes(Type.GRASS) && !target.getTag(SemiInvulnerableTag)), + .attr(StatStageChangeAttr, [ Stat.DEF ], 1, false, { condition: (user, target, move) => target.getTypes().includes(Type.GRASS) && !target.getTag(SemiInvulnerableTag) }), new StatusMove(Moves.GRASSY_TERRAIN, Type.GRASS, -1, 10, -1, 0, 6) .attr(TerrainChangeAttr, TerrainType.GRASSY) .target(MoveTarget.BOTH_SIDES), @@ -9070,7 +9237,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.SPATK ], -1) .soundBased(), new AttackMove(Moves.DIAMOND_STORM, Type.ROCK, MoveCategory.PHYSICAL, 100, 95, 5, 50, 0, 6) - .attr(StatStageChangeAttr, [ Stat.DEF ], 2, true, undefined, undefined, undefined, undefined, true) + .attr(StatStageChangeAttr, [ Stat.DEF ], 2, true, { firstTargetOnly: true }) .makesContact(false) .target(MoveTarget.ALL_NEAR_ENEMIES), new AttackMove(Moves.STEAM_ERUPTION, Type.WATER, MoveCategory.SPECIAL, 110, 95, 5, 30, 0, 6) @@ -9096,7 +9263,7 @@ export function initMoves() { new StatusMove(Moves.EERIE_IMPULSE, Type.ELECTRIC, 100, 15, -1, 0, 6) .attr(StatStageChangeAttr, [ Stat.SPATK ], -2), new StatusMove(Moves.VENOM_DRENCH, Type.POISON, 100, 20, -1, 0, 6) - .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK, Stat.SPD ], -1, false, (user, target, move) => target.status?.effect === StatusEffect.POISON || target.status?.effect === StatusEffect.TOXIC) + .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK, Stat.SPD ], -1, false, { condition: (user, target, move) => target.status?.effect === StatusEffect.POISON || target.status?.effect === StatusEffect.TOXIC }) .target(MoveTarget.ALL_NEAR_ENEMIES), new StatusMove(Moves.POWDER, Type.BUG, 100, 20, -1, 1, 6) .ignoresSubstitute() @@ -9107,7 +9274,7 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.SPATK, Stat.SPDEF, Stat.SPD ], 2, true) .ignoresVirtual(), new StatusMove(Moves.MAGNETIC_FLUX, Type.ELECTRIC, -1, 20, -1, 0, 6) - .attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], 1, false, (user, target, move) => !![ Abilities.PLUS, Abilities.MINUS ].find(a => target.hasAbility(a, false))) + .attr(StatStageChangeAttr, [ Stat.DEF, Stat.SPDEF ], 1, false, { condition: (user, target, move) => !![ Abilities.PLUS, Abilities.MINUS ].find(a => target.hasAbility(a, false)) }) .ignoresSubstitute() .target(MoveTarget.USER_AND_ALLIES) .condition((user, target, move) => !![ user, user.getAlly() ].filter(p => p?.isActive()).find(p => !![ Abilities.PLUS, Abilities.MINUS ].find(a => p.hasAbility(a, false)))), @@ -9327,7 +9494,7 @@ export function initMoves() { new SelfStatusMove(Moves.LASER_FOCUS, Type.NORMAL, -1, 30, -1, 0, 7) .attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false), new StatusMove(Moves.GEAR_UP, Type.STEEL, -1, 20, -1, 0, 7) - .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, false, (user, target, move) => !![ Abilities.PLUS, Abilities.MINUS ].find(a => target.hasAbility(a, false))) + .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, false, { condition: (user, target, move) => !![ Abilities.PLUS, Abilities.MINUS ].find(a => target.hasAbility(a, false)) }) .ignoresSubstitute() .target(MoveTarget.USER_AND_ALLIES) .condition((user, target, move) => !![ user, user.getAlly() ].filter(p => p?.isActive()).find(p => !![ Abilities.PLUS, Abilities.MINUS ].find(a => p.hasAbility(a, false)))), @@ -9384,7 +9551,7 @@ export function initMoves() { .ballBombMove() .makesContact(false), new AttackMove(Moves.CLANGING_SCALES, Type.DRAGON, MoveCategory.SPECIAL, 110, 100, 5, -1, 0, 7) - .attr(StatStageChangeAttr, [ Stat.DEF ], -1, true, null, true, false, MoveEffectTrigger.HIT, true) + .attr(StatStageChangeAttr, [ Stat.DEF ], -1, true, { firstTargetOnly: true }) .soundBased() .target(MoveTarget.ALL_NEAR_ENEMIES), new AttackMove(Moves.DRAGON_HAMMER, Type.DRAGON, MoveCategory.PHYSICAL, 90, 100, 15, -1, 0, 7), @@ -9498,7 +9665,7 @@ export function initMoves() { .makesContact(false) .ignoresVirtual(), new AttackMove(Moves.CLANGOROUS_SOULBLAZE, Type.DRAGON, MoveCategory.SPECIAL, 185, -1, 1, 100, 0, 7) - .attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, true, undefined, undefined, undefined, undefined, true) + .attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, true, { firstTargetOnly: true }) .soundBased() .target(MoveTarget.ALL_NEAR_ENEMIES) .edgeCase() // I assume it needs clanging scales and Kommo-O @@ -9736,8 +9903,8 @@ export function initMoves() { .attr(ClearTerrainAttr) .condition((user, target, move) => !!user.scene.arena.terrain), new AttackMove(Moves.SCALE_SHOT, Type.DRAGON, MoveCategory.PHYSICAL, 25, 90, 20, -1, 0, 8) - .attr(StatStageChangeAttr, [ Stat.SPD ], 1, true, null, true, false, MoveEffectTrigger.HIT, false, true) - .attr(StatStageChangeAttr, [ Stat.DEF ], -1, true, null, true, false, MoveEffectTrigger.HIT, false, true) + .attr(StatStageChangeAttr, [ Stat.SPD ], 1, true, { lastHitOnly: true }) + .attr(StatStageChangeAttr, [ Stat.DEF ], -1, true, { lastHitOnly: true }) .attr(MultiHitAttr) .makesContact(false), new ChargingAttackMove(Moves.METEOR_BEAM, Type.ROCK, MoveCategory.SPECIAL, 120, 90, 10, -1, 0, 8) @@ -9871,7 +10038,7 @@ export function initMoves() { new AttackMove(Moves.TRIPLE_ARROWS, Type.FIGHTING, MoveCategory.PHYSICAL, 90, 100, 10, 30, 0, 8) .makesContact(false) .attr(HighCritAttr) - .attr(StatStageChangeAttr, [ Stat.DEF ], -1, undefined, undefined, undefined, undefined, undefined, undefined, undefined, 50) + .attr(StatStageChangeAttr, [ Stat.DEF ], -1, false, { effectChanceOverride: 50 }) .attr(FlinchAttr), new AttackMove(Moves.INFERNAL_PARADE, Type.GHOST, MoveCategory.SPECIAL, 60, 100, 15, 30, 0, 8) .attr(StatusEffectAttr, StatusEffect.BURN) @@ -10007,7 +10174,7 @@ export function initMoves() { .attr(TeraMoveCategoryAttr) .attr(TeraBlastTypeAttr) .attr(TeraBlastPowerAttr) - .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, true, (user, target, move) => user.isTerastallized() && user.isOfType(Type.STELLAR)) + .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1, true, { condition: (user, target, move) => user.isTerastallized() && user.isOfType(Type.STELLAR) }) .partial(), /** Does not ignore abilities that affect stats, relevant in determining the move's category {@see TeraMoveCategoryAttr} */ new SelfStatusMove(Moves.SILK_TRAP, Type.BUG, -1, 10, -1, 4, 9) .attr(ProtectAttr, BattlerTagType.SILK_TRAP) @@ -10091,7 +10258,7 @@ export function initMoves() { .attr(RemoveScreensAttr), new AttackMove(Moves.MAKE_IT_RAIN, Type.STEEL, MoveCategory.SPECIAL, 120, 100, 5, -1, 0, 9) .attr(MoneyAttr) - .attr(StatStageChangeAttr, [ Stat.SPATK ], -1, true, null, true, false, MoveEffectTrigger.HIT, true) + .attr(StatStageChangeAttr, [ Stat.SPATK ], -1, true, { firstTargetOnly: true }) .target(MoveTarget.ALL_NEAR_ENEMIES), new AttackMove(Moves.PSYBLADE, Type.PSYCHIC, MoveCategory.PHYSICAL, 80, 100, 15, -1, 0, 9) .attr(MovePowerMultiplierAttr, (user, target, move) => user.scene.arena.getTerrainType() === TerrainType.ELECTRIC && user.isGrounded() ? 1.5 : 1) @@ -10108,12 +10275,13 @@ export function initMoves() { .makesContact(), new SelfStatusMove(Moves.SHED_TAIL, Type.NORMAL, -1, 10, -1, 0, 9) .attr(AddSubstituteAttr, 0.5) - .attr(ForceSwitchOutAttr, true, SwitchType.SHED_TAIL), + .attr(ForceSwitchOutAttr, true, SwitchType.SHED_TAIL) + .condition(failIfLastInPartyCondition), new SelfStatusMove(Moves.CHILLY_RECEPTION, Type.ICE, -1, 10, -1, 0, 9) .attr(PreMoveMessageAttr, (user, move) => i18next.t("moveTriggers:chillyReception", { pokemonName: getPokemonNameWithAffix(user) })) .attr(ChillyReceptionAttr, true), new SelfStatusMove(Moves.TIDY_UP, Type.NORMAL, -1, 10, -1, 0, 9) - .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPD ], 1, true, null, true, true) + .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPD ], 1, true) .attr(RemoveArenaTrapAttr, true) .attr(RemoveAllSubstitutesAttr), new StatusMove(Moves.SNOWSCAPE, Type.ICE, -1, 10, -1, 0, 9) diff --git a/src/data/mystery-encounters/encounters/clowning-around-encounter.ts b/src/data/mystery-encounters/encounters/clowning-around-encounter.ts index c4b03660bde..0a88c5a699c 100644 --- a/src/data/mystery-encounters/encounters/clowning-around-encounter.ts +++ b/src/data/mystery-encounters/encounters/clowning-around-encounter.ts @@ -1,7 +1,7 @@ import { EnemyPartyConfig, generateModifierType, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, loadCustomMovesForEncounter, selectPokemonForOption, setEncounterRewards, transitionMysteryEncounterIntroVisuals } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; import { trainerConfigs, TrainerPartyCompoundTemplate, TrainerPartyTemplate, } from "#app/data/trainer-config"; import { ModifierTier } from "#app/modifier/modifier-tier"; -import { modifierTypes, PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; +import { ModifierPoolType, modifierTypes, PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { PartyMemberStrength } from "#enums/party-member-strength"; import BattleScene from "#app/battle-scene"; @@ -280,7 +280,7 @@ export const ClowningAroundEncounter: MysteryEncounter = let numRogue = 0; items.filter(m => m.isTransferable && !(m instanceof BerryModifier)) .forEach(m => { - const type = m.type.withTierFromPool(); + const type = m.type.withTierFromPool(ModifierPoolType.PLAYER, party); const tier = type.tier ?? ModifierTier.ULTRA; if (type.id === "GOLDEN_EGG" || tier === ModifierTier.ROGUE) { numRogue += m.stackCount; diff --git a/src/data/mystery-encounters/utils/encounter-phase-utils.ts b/src/data/mystery-encounters/utils/encounter-phase-utils.ts index 5cd2fbffd5f..66459c96ede 100644 --- a/src/data/mystery-encounters/utils/encounter-phase-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-phase-utils.ts @@ -418,7 +418,7 @@ export function generateModifierType(scene: BattleScene, modifier: () => Modifie // Populates item id and tier (order matters) result = result .withIdFromFunc(modifierTypes[modifierId]) - .withTierFromPool(); + .withTierFromPool(ModifierPoolType.PLAYER, scene.getParty()); return result instanceof ModifierTypeGenerator ? result.generateType(scene.getParty(), pregenArgs) : result; } diff --git a/src/field/arena.ts b/src/field/arena.ts index b053a3d056a..abc2b89569c 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -224,66 +224,6 @@ export class Arena { return 0; } - getTypeForBiome() { - switch (this.biomeType) { - case Biome.TOWN: - case Biome.PLAINS: - case Biome.METROPOLIS: - return Type.NORMAL; - case Biome.GRASS: - case Biome.TALL_GRASS: - return Type.GRASS; - case Biome.FOREST: - case Biome.JUNGLE: - return Type.BUG; - case Biome.SLUM: - case Biome.SWAMP: - return Type.POISON; - case Biome.SEA: - case Biome.BEACH: - case Biome.LAKE: - case Biome.SEABED: - return Type.WATER; - case Biome.MOUNTAIN: - return Type.FLYING; - case Biome.BADLANDS: - return Type.GROUND; - case Biome.CAVE: - case Biome.DESERT: - return Type.ROCK; - case Biome.ICE_CAVE: - case Biome.SNOWY_FOREST: - return Type.ICE; - case Biome.MEADOW: - case Biome.FAIRY_CAVE: - case Biome.ISLAND: - return Type.FAIRY; - case Biome.POWER_PLANT: - return Type.ELECTRIC; - case Biome.VOLCANO: - return Type.FIRE; - case Biome.GRAVEYARD: - case Biome.TEMPLE: - return Type.GHOST; - case Biome.DOJO: - case Biome.CONSTRUCTION_SITE: - return Type.FIGHTING; - case Biome.FACTORY: - case Biome.LABORATORY: - return Type.STEEL; - case Biome.RUINS: - case Biome.SPACE: - return Type.PSYCHIC; - case Biome.WASTELAND: - case Biome.END: - return Type.DRAGON; - case Biome.ABYSS: - return Type.DARK; - default: - return Type.UNKNOWN; - } - } - getBgTerrainColorRatioForBiome(): number { switch (this.biomeType) { case Biome.SPACE: diff --git a/src/modifier/modifier-type.ts b/src/modifier/modifier-type.ts index e68e9a06fae..dfa46ce3667 100644 --- a/src/modifier/modifier-type.ts +++ b/src/modifier/modifier-type.ts @@ -19,7 +19,7 @@ import { Unlockables } from "#app/system/unlockables"; import { getVoucherTypeIcon, getVoucherTypeName, VoucherType } from "#app/system/voucher"; import PartyUiHandler, { PokemonMoveSelectFilter, PokemonSelectFilter } from "#app/ui/party-ui-handler"; import { getModifierTierTextTint } from "#app/ui/text"; -import { formatMoney, getEnumKeys, getEnumValues, IntegerHolder, NumberHolder, padInt, randSeedInt, randSeedItem } from "#app/utils"; +import { formatMoney, getEnumKeys, getEnumValues, IntegerHolder, isNullOrUndefined, NumberHolder, padInt, randSeedInt, randSeedItem } from "#app/utils"; import { Abilities } from "#enums/abilities"; import { BattlerTagType } from "#enums/battler-tag-type"; import { BerryType } from "#enums/berry-type"; @@ -121,18 +121,41 @@ export class ModifierType { * Populates item tier for ModifierType instance * Tier is a necessary field for items that appear in player shop (determines the Pokeball visual they use) * To find the tier, this function performs a reverse lookup of the item type in modifier pools + * It checks the weight of the item and will use the first tier for which the weight is greater than 0 + * This is to allow items to be in multiple item pools depending on the conditions, for example for events + * If all tiers have a weight of 0 for the item, the first tier where the item was found is used * @param poolType Default 'ModifierPoolType.PLAYER'. Which pool to lookup item tier from + * @param party optional. Needed to check the weight of modifiers with conditional weight (see {@linkcode WeightedModifierTypeWeightFunc}) + * if not provided or empty, the weight check will be ignored + * @param rerollCount Default `0`. Used to check the weight of modifiers with conditional weight (see {@linkcode WeightedModifierTypeWeightFunc}) */ - withTierFromPool(poolType: ModifierPoolType = ModifierPoolType.PLAYER): ModifierType { + withTierFromPool(poolType: ModifierPoolType = ModifierPoolType.PLAYER, party?: PlayerPokemon[], rerollCount: number = 0): ModifierType { + let defaultTier: undefined | ModifierTier; for (const tier of Object.values(getModifierPoolForType(poolType))) { for (const modifier of tier) { if (this.id === modifier.modifierType.id) { - this.tier = modifier.modifierType.tier; - return this; + let weight: number; + if (modifier.weight instanceof Function) { + weight = party ? modifier.weight(party, rerollCount) : 0; + } else { + weight = modifier.weight; + } + if (weight > 0) { + this.tier = modifier.modifierType.tier; + return this; + } else if (isNullOrUndefined(defaultTier)) { + // If weight is 0, keep track of the first tier where the item was found + defaultTier = modifier.modifierType.tier; + } } } } + // Didn't find a pool with weight > 0, fallback to first tier where the item was found, if any + if (defaultTier) { + this.tier = defaultTier; + } + return this; } @@ -2117,7 +2140,7 @@ export function getPlayerModifierTypeOptions(count: integer, party: PlayerPokemo // Populates item id and tier guaranteedMod = guaranteedMod .withIdFromFunc(modifierTypes[modifierId]) - .withTierFromPool(); + .withTierFromPool(ModifierPoolType.PLAYER, party); const modType = guaranteedMod instanceof ModifierTypeGenerator ? guaranteedMod.generateType(party) : guaranteedMod; if (modType) { @@ -2186,7 +2209,7 @@ export function overridePlayerModifierTypeOptions(options: ModifierTypeOption[], } if (modifierType) { - options[i].type = modifierType.withIdFromFunc(modifierFunc).withTierFromPool(); + options[i].type = modifierType.withIdFromFunc(modifierFunc).withTierFromPool(ModifierPoolType.PLAYER, party); } } } diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 2b898f7d66b..8b4b462380c 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -1,20 +1,62 @@ -import BattleScene from "#app/battle-scene"; import { BattlerIndex } from "#app/battle"; -import { applyPreAttackAbAttrs, AddSecondStrikeAbAttr, IgnoreMoveEffectsAbAttr, applyPostDefendAbAttrs, PostDefendAbAttr, applyPostAttackAbAttrs, PostAttackAbAttr, MaxMultiHitAbAttr, AlwaysHitAbAttr, TypeImmunityAbAttr } from "#app/data/ability"; +import BattleScene from "#app/battle-scene"; +import { + AddSecondStrikeAbAttr, + AlwaysHitAbAttr, + applyPostAttackAbAttrs, + applyPostDefendAbAttrs, + applyPreAttackAbAttrs, + IgnoreMoveEffectsAbAttr, + MaxMultiHitAbAttr, + PostAttackAbAttr, + PostDefendAbAttr, + TypeImmunityAbAttr, +} from "#app/data/ability"; import { ArenaTagSide, ConditionalProtectTag } from "#app/data/arena-tag"; import { MoveAnim } from "#app/data/battle-anims"; -import { BattlerTagLapseType, DamageProtectedTag, ProtectedTag, SemiInvulnerableTag, SubstituteTag } from "#app/data/battler-tags"; -import { MoveTarget, applyMoveAttrs, OverrideMoveEffectAttr, MultiHitAttr, AttackMove, FixedDamageAttr, VariableTargetAttr, MissEffectAttr, MoveFlags, applyFilteredMoveAttrs, MoveAttr, MoveEffectAttr, OneHitKOAttr, MoveEffectTrigger, MoveCategory, NoEffectAttr, HitsTagAttr, ToxicAccuracyAttr } from "#app/data/move"; +import { + BattlerTagLapseType, + DamageProtectedTag, + ProtectedTag, + SemiInvulnerableTag, + SubstituteTag, +} from "#app/data/battler-tags"; +import { + applyFilteredMoveAttrs, + applyMoveAttrs, + AttackMove, + FixedDamageAttr, + HitsTagAttr, + MissEffectAttr, + MoveAttr, + MoveCategory, + MoveEffectAttr, + MoveEffectTrigger, + MoveFlags, + MoveTarget, + MultiHitAttr, + NoEffectAttr, + OneHitKOAttr, + OverrideMoveEffectAttr, + ToxicAccuracyAttr, + VariableTargetAttr, +} from "#app/data/move"; import { SpeciesFormChangePostMoveTrigger } from "#app/data/pokemon-forms"; -import { BattlerTagType } from "#app/enums/battler-tag-type"; -import { Moves } from "#app/enums/moves"; -import Pokemon, { PokemonMove, MoveResult, HitResult } from "#app/field/pokemon"; -import { getPokemonNameWithAffix } from "#app/messages"; -import { PokemonMultiHitModifier, FlinchChanceModifier, EnemyAttackStatusEffectChanceModifier, ContactHeldItemTransferChanceModifier, HitHealModifier } from "#app/modifier/modifier"; -import i18next from "i18next"; -import * as Utils from "#app/utils"; -import { PokemonPhase } from "./pokemon-phase"; import { Type } from "#app/data/type"; +import Pokemon, { HitResult, MoveResult, PokemonMove } from "#app/field/pokemon"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { + ContactHeldItemTransferChanceModifier, + EnemyAttackStatusEffectChanceModifier, + FlinchChanceModifier, + HitHealModifier, + PokemonMultiHitModifier, +} from "#app/modifier/modifier"; +import { BooleanHolder, executeIf, NumberHolder } from "#app/utils"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { Moves } from "#enums/moves"; +import i18next from "i18next"; +import { PokemonPhase } from "./pokemon-phase"; export class MoveEffectPhase extends PokemonPhase { public move: PokemonMove; @@ -35,7 +77,7 @@ export class MoveEffectPhase extends PokemonPhase { this.targets = targets; } - start() { + public override start(): void { super.start(); /** The Pokemon using this phase's invoked move */ @@ -52,12 +94,12 @@ export class MoveEffectPhase extends PokemonPhase { * Does an effect from this move override other effects on this turn? * e.g. Charging moves (Fly, etc.) on their first turn of use. */ - const overridden = new Utils.BooleanHolder(false); + const overridden = new BooleanHolder(false); /** The {@linkcode Move} object from {@linkcode allMoves} invoked by this phase */ const move = this.move.getMove(); // Assume single target for override - applyMoveAttrs(OverrideMoveEffectAttr, user, this.getTarget() ?? null, move, overridden, this.move.virtual).then(() => { + applyMoveAttrs(OverrideMoveEffectAttr, user, this.getFirstTarget() ?? null, move, overridden, this.move.virtual).then(() => { // If other effects were overriden, stop this phase before they can be applied if (overridden.value) { return this.end(); @@ -71,14 +113,14 @@ export class MoveEffectPhase extends PokemonPhase { * effects of the move itself, Parental Bond, and Multi-Lens to do so. */ if (user.turnData.hitsLeft === -1) { - const hitCount = new Utils.IntegerHolder(1); + const hitCount = new NumberHolder(1); // Assume single target for multi hit - applyMoveAttrs(MultiHitAttr, user, this.getTarget() ?? null, move, hitCount); + applyMoveAttrs(MultiHitAttr, user, this.getFirstTarget() ?? null, move, hitCount); // If Parental Bond is applicable, double the hit count - applyPreAttackAbAttrs(AddSecondStrikeAbAttr, user, null, move, false, targets.length, hitCount, new Utils.IntegerHolder(0)); + applyPreAttackAbAttrs(AddSecondStrikeAbAttr, user, null, move, false, targets.length, hitCount, new NumberHolder(0)); // If Multi-Lens is applicable, multiply the hit count by 1 + the number of Multi-Lenses held by the user if (move instanceof AttackMove && !move.hasAttr(FixedDamageAttr)) { - this.scene.applyModifiers(PokemonMultiHitModifier, user.isPlayer(), user, hitCount, new Utils.IntegerHolder(0)); + this.scene.applyModifiers(PokemonMultiHitModifier, user.isPlayer(), user, hitCount, new NumberHolder(0)); } // Set the user's relevant turnData fields to reflect the final hit count user.turnData.hitCount = hitCount.value; @@ -100,8 +142,9 @@ export class MoveEffectPhase extends PokemonPhase { const hasActiveTargets = targets.some(t => t.isActive(true)); /** Check if the target is immune via ability to the attacking move, and NOT in semi invulnerable state */ - const isImmune = targets[0].hasAbilityWithAttr(TypeImmunityAbAttr) && (targets[0].getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move)) - && !targets[0].getTag(SemiInvulnerableTag); + const isImmune = targets[0].hasAbilityWithAttr(TypeImmunityAbAttr) + && (targets[0].getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move)) + && !targets[0].getTag(SemiInvulnerableTag); /** * If no targets are left for the move to hit (FAIL), or the invoked move is single-target @@ -111,7 +154,7 @@ export class MoveEffectPhase extends PokemonPhase { if (!hasActiveTargets || (!move.hasAttr(VariableTargetAttr) && !move.isMultiTarget() && !targetHitChecks[this.targets[0]] && !targets[0].getTag(ProtectedTag) && !isImmune)) { this.stopMultiHit(); if (hasActiveTargets) { - this.scene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: this.getTarget() ? getPokemonNameWithAffix(this.getTarget()!) : "" })); + this.scene.queueMessage(i18next.t("battle:attackMissed", { pokemonNameWithAffix: this.getFirstTarget() ? getPokemonNameWithAffix(this.getFirstTarget()!) : "" })); moveHistoryEntry.result = MoveResult.MISS; applyMoveAttrs(MissEffectAttr, user, null, move); } else { @@ -127,30 +170,40 @@ export class MoveEffectPhase extends PokemonPhase { const playOnEmptyField = this.scene.currentBattle?.mysteryEncounter?.hasBattleAnimationsWithoutTargets ?? false; // Move animation only needs one target - new MoveAnim(move.id as Moves, user, this.getTarget()!.getBattlerIndex()!, playOnEmptyField).play(this.scene, move.hitsSubstitute(user, this.getTarget()!), () => { + new MoveAnim(move.id as Moves, user, this.getFirstTarget()!.getBattlerIndex()!, playOnEmptyField).play(this.scene, move.hitsSubstitute(user, this.getFirstTarget()!), () => { /** Has the move successfully hit a target (for damage) yet? */ let hasHit: boolean = false; for (const target of targets) { + // Prevent ENEMY_SIDE targeted moves from occurring twice in double battles + if (move.moveTarget === MoveTarget.ENEMY_SIDE && target !== targets[targets.length - 1]) { + continue; + } /** The {@linkcode ArenaTagSide} to which the target belongs */ const targetSide = target.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; /** Has the invoked move been cancelled by conditional protection (e.g Quick Guard)? */ - const hasConditionalProtectApplied = new Utils.BooleanHolder(false); + const hasConditionalProtectApplied = new BooleanHolder(false); /** Does the applied conditional protection bypass Protect-ignoring effects? */ - const bypassIgnoreProtect = new Utils.BooleanHolder(false); + const bypassIgnoreProtect = new BooleanHolder(false); /** If the move is not targeting a Pokemon on the user's side, try to apply conditional protection effects */ if (!this.move.getMove().isAllyTarget()) { this.scene.arena.applyTagsForSide(ConditionalProtectTag, targetSide, false, hasConditionalProtectApplied, user, target, move.id, bypassIgnoreProtect); } /** Is the target protected by Protect, etc. or a relevant conditional protection effect? */ - const isProtected = (bypassIgnoreProtect.value || !this.move.getMove().checkFlag(MoveFlags.IGNORE_PROTECT, user, target)) - && (hasConditionalProtectApplied.value || (!target.findTags(t => t instanceof DamageProtectedTag).length && target.findTags(t => t instanceof ProtectedTag).find(t => target.lapseTag(t.tagType))) - || (this.move.getMove().category !== MoveCategory.STATUS && target.findTags(t => t instanceof DamageProtectedTag).find(t => target.lapseTag(t.tagType)))); + const isProtected = ( + bypassIgnoreProtect.value + || !this.move.getMove().checkFlag(MoveFlags.IGNORE_PROTECT, user, target)) + && (hasConditionalProtectApplied.value + || (!target.findTags(t => t instanceof DamageProtectedTag).length + && target.findTags(t => t instanceof ProtectedTag).find(t => target.lapseTag(t.tagType))) + || (this.move.getMove().category !== MoveCategory.STATUS + && target.findTags(t => t instanceof DamageProtectedTag).find(t => target.lapseTag(t.tagType)))); /** Is the pokemon immune due to an ablility, and also not in a semi invulnerable state? */ - const isImmune = target.hasAbilityWithAttr(TypeImmunityAbAttr) && (target.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move)) - && !target.getTag(SemiInvulnerableTag); + const isImmune = target.hasAbilityWithAttr(TypeImmunityAbAttr) + && (target.getAbility()?.getAttrs(TypeImmunityAbAttr)?.[0]?.getImmuneType() === user.getMoveType(move)) + && !target.getTag(SemiInvulnerableTag); /** * If the move missed a target, stop all future hits against that target @@ -218,7 +271,7 @@ export class MoveEffectPhase extends PokemonPhase { } /** Does this phase represent the invoked move's last strike? */ - const lastHit = (user.turnData.hitsLeft === 1 || !this.getTarget()?.isActive()); + const lastHit = (user.turnData.hitsLeft === 1 || !this.getFirstTarget()?.isActive()); /** * If the user can change forms by using the invoked move, @@ -234,85 +287,48 @@ export class MoveEffectPhase extends PokemonPhase { * These are ordered by trigger type (see {@linkcode MoveEffectTrigger}), and each trigger * type requires different conditions to be met with respect to the move's hit result. */ - applyAttrs.push(new Promise(resolve => { - // Apply all effects with PRE_MOVE triggers (if the target isn't immune to the move) - applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.PRE_APPLY && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit) && hitResult !== HitResult.NO_EFFECT, - user, target, move).then(() => { - // All other effects require the move to not have failed or have been cancelled to trigger - if (hitResult !== HitResult.FAIL) { - /** - * If the invoked move's effects are meant to trigger during the move's "charge turn," - * ignore all effects after this point. - * Otherwise, apply all self-targeted POST_APPLY effects. - */ - applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_APPLY - && attr.selfTarget && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, move).then(() => { - // All effects past this point require the move to have hit the target - if (hitResult !== HitResult.NO_EFFECT) { - // Apply all non-self-targeted POST_APPLY effects - applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.POST_APPLY - && !(attr as MoveEffectAttr).selfTarget && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit), user, target, this.move.getMove()).then(() => { - /** - * If the move hit, and the target doesn't have Shield Dust, - * apply the chance to flinch the target gained from King's Rock - */ - if (dealsDamage && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && !move.hitsSubstitute(user, target)) { - const flinched = new Utils.BooleanHolder(false); - user.scene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched); - if (flinched.value) { - target.addTag(BattlerTagType.FLINCHED, undefined, this.move.moveId, user.id); - } - } - // If the move was not protected against, apply all HIT effects - Utils.executeIf(!isProtected, () => applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && (attr as MoveEffectAttr).trigger === MoveEffectTrigger.HIT - && (!attr.firstHitOnly || firstHit) && (!attr.lastHitOnly || lastHit) && (!attr.firstTargetOnly || firstTarget), user, target, this.move.getMove()).then(() => { - // Apply the target's post-defend ability effects (as long as the target is active or can otherwise apply them) - return Utils.executeIf(!target.isFainted() || target.canApplyAbility(), () => applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move.getMove(), hitResult).then(() => { - // Only apply the following effects if the move was not deflected by a substitute - if (move.hitsSubstitute(user, target)) { - return resolve(); - } + const k = new Promise((resolve) => { + //Start promise chain and apply PRE_APPLY move attributes + let promiseChain: Promise = applyFilteredMoveAttrs((attr: MoveAttr) => + attr instanceof MoveEffectAttr + && attr.trigger === MoveEffectTrigger.PRE_APPLY + && (!attr.firstHitOnly || firstHit) + && (!attr.lastHitOnly || lastHit) + && hitResult !== HitResult.NO_EFFECT, user, target, move); - // If the invoked move is an enemy attack, apply the enemy's status effect-inflicting tokens - if (!user.isPlayer() && this.move.getMove() instanceof AttackMove) { - user.scene.applyShuffledModifiers(this.scene, EnemyAttackStatusEffectChanceModifier, false, target); - } - target.lapseTags(BattlerTagLapseType.AFTER_HIT); + /** Don't complete if the move failed */ + if (hitResult === HitResult.FAIL) { + return resolve(); + } - })).then(() => { - // Apply the user's post-attack ability effects - applyPostAttackAbAttrs(PostAttackAbAttr, user, target, this.move.getMove(), hitResult).then(() => { - /** - * If the invoked move is an attack, apply the user's chance to - * steal an item from the target granted by Grip Claw - */ - if (this.move.getMove() instanceof AttackMove) { - this.scene.applyModifiers(ContactHeldItemTransferChanceModifier, this.player, user, target); - } - resolve(); - }); - }); - }) - ).then(() => resolve()); - }); - } else { - applyMoveAttrs(NoEffectAttr, user, null, move).then(() => resolve()); - } - }); - } else { - resolve(); - } - }); - })); + /** Apply Move/Ability Effects in correct order */ + promiseChain = promiseChain + .then(this.applySelfTargetEffects(user, target, firstHit, lastHit)); + + if (hitResult !== HitResult.NO_EFFECT) { + promiseChain + .then(this.applyPostApplyEffects(user, target, firstHit, lastHit)) + .then(this.applyHeldItemFlinchCheck(user, target, dealsDamage)) + .then(this.applySuccessfulAttackEffects(user, target, firstHit, lastHit, !!isProtected, hitResult, firstTarget)) + .then(() => resolve()); + } else { + promiseChain + .then(() => applyMoveAttrs(NoEffectAttr, user, null, move)) + .then(resolve); + } + }); + + applyAttrs.push(k); } + // Apply the move's POST_TARGET effects on the move's last hit, after all targeted effects have resolved - const postTarget = (user.turnData.hitsLeft === 1 || !this.getTarget()?.isActive()) ? + const postTarget = (user.turnData.hitsLeft === 1 || !this.getFirstTarget()?.isActive()) ? applyFilteredMoveAttrs((attr: MoveAttr) => attr instanceof MoveEffectAttr && attr.trigger === MoveEffectTrigger.POST_TARGET, user, null, move) : null; - if (!!postTarget) { + if (postTarget) { if (applyAttrs.length) { // If there is a pending asynchronous move effect, do this after - applyAttrs[applyAttrs.length - 1]?.then(() => postTarget); + applyAttrs[applyAttrs.length - 1].then(() => postTarget); } else { // Otherwise, push a new asynchronous move effect applyAttrs.push(postTarget); } @@ -327,7 +343,7 @@ export class MoveEffectPhase extends PokemonPhase { */ targets.forEach(target => { const substitute = target.getTag(SubstituteTag); - if (!!substitute && substitute.hp <= 0) { + if (substitute && substitute.hp <= 0) { target.lapseTag(BattlerTagType.SUBSTITUTE); } }); @@ -337,7 +353,7 @@ export class MoveEffectPhase extends PokemonPhase { }); } - end() { + public override end(): void { const user = this.getUserPokemon(); /** * If this phase isn't for the invoked move's last strike, @@ -347,7 +363,7 @@ export class MoveEffectPhase extends PokemonPhase { * to the user. */ if (user) { - if (user.turnData.hitsLeft && --user.turnData.hitsLeft >= 1 && this.getTarget()?.isActive()) { + if (user.turnData.hitsLeft && --user.turnData.hitsLeft >= 1 && this.getFirstTarget()?.isActive()) { this.scene.unshiftPhase(this.getNewHitPhase()); } else { // Queue message for number of hits made by multi-move @@ -367,11 +383,135 @@ export class MoveEffectPhase extends PokemonPhase { } /** - * Resolves whether this phase's invoked move hits or misses the given target - * @param target {@linkcode Pokemon} the Pokemon targeted by the invoked move - * @returns `true` if the move does not miss the target; `false` otherwise - */ - hitCheck(target: Pokemon): boolean { + * Apply self-targeted effects that trigger `POST_APPLY` + * + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - {@linkcode Pokemon} the current target of this phase's invoked move + * @param firstHit - `true` if this is the first hit in a multi-hit attack + * @param lastHit - `true` if this is the last hit in a multi-hit attack + * @returns a function intended to be passed into a `then()` call. + */ + protected applySelfTargetEffects(user: Pokemon, target: Pokemon, firstHit: boolean, lastHit: boolean): () => Promise { + return () => applyFilteredMoveAttrs((attr: MoveAttr) => + attr instanceof MoveEffectAttr + && attr.trigger === MoveEffectTrigger.POST_APPLY + && attr.selfTarget + && (!attr.firstHitOnly || firstHit) + && (!attr.lastHitOnly || lastHit), user, target, this.move.getMove()); + } + + /** + * Applies non-self-targeted effects that trigger `POST_APPLY` + * (i.e. Smelling Salts curing Paralysis, and the forced switch from U-Turn, Dragon Tail, etc) + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - {@linkcode Pokemon} the current target of this phase's invoked move + * @param firstHit - `true` if this is the first hit in a multi-hit attack + * @param lastHit - `true` if this is the last hit in a multi-hit attack + * @returns a function intended to be passed into a `then()` call. + */ + protected applyPostApplyEffects(user: Pokemon, target: Pokemon, firstHit: boolean, lastHit: boolean): () => Promise { + return () => applyFilteredMoveAttrs((attr: MoveAttr) => + attr instanceof MoveEffectAttr + && attr.trigger === MoveEffectTrigger.POST_APPLY + && !attr.selfTarget + && (!attr.firstHitOnly || firstHit) + && (!attr.lastHitOnly || lastHit), user, target, this.move.getMove()); + } + + /** + * Applies effects that trigger on HIT + * (i.e. Final Gambit, Power-Up Punch, Drain Punch) + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - {@linkcode Pokemon} the current target of this phase's invoked move + * @param firstHit - `true` if this is the first hit in a multi-hit attack + * @param lastHit - `true` if this is the last hit in a multi-hit attack + * @param firstTarget - `true` if {@linkcode target} is the first target hit by this strike of {@linkcode move} + * @returns a function intended to be passed into a `then()` call. + */ + protected applyOnHitEffects(user: Pokemon, target: Pokemon, firstHit : boolean, lastHit: boolean, firstTarget: boolean): Promise { + return applyFilteredMoveAttrs((attr: MoveAttr) => + attr instanceof MoveEffectAttr + && attr.trigger === MoveEffectTrigger.HIT + && (!attr.firstHitOnly || firstHit) + && (!attr.lastHitOnly || lastHit) + && (!attr.firstTargetOnly || firstTarget), user, target, this.move.getMove()); + } + + /** + * Applies reactive effects that occur when a Pokémon is hit. + * (i.e. Effect Spore, Disguise, Liquid Ooze, Beak Blast) + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - {@linkcode Pokemon} the current target of this phase's invoked move + * @param hitResult - The {@linkcode HitResult} of the attempted move + * @returns a `Promise` intended to be passed into a `then()` call. + */ + protected applyOnGetHitAbEffects(user: Pokemon, target: Pokemon, hitResult: HitResult): Promise { + return executeIf(!target.isFainted() || target.canApplyAbility(), () => + applyPostDefendAbAttrs(PostDefendAbAttr, target, user, this.move.getMove(), hitResult) + .then(() => { + + if (!this.move.getMove().hitsSubstitute(user, target)) { + if (!user.isPlayer() && this.move.getMove() instanceof AttackMove) { + user.scene.applyShuffledModifiers(this.scene, EnemyAttackStatusEffectChanceModifier, false, target); + } + + target.lapseTags(BattlerTagLapseType.AFTER_HIT); + } + + }) + ); + } + + /** + * Applies all effects and attributes that require a move to connect with a target, + * namely reactive effects like Weak Armor, on-hit effects like that of Power-Up Punch, and item stealing effects + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - {@linkcode Pokemon} the current target of this phase's invoked move + * @param firstHit - `true` if this is the first hit in a multi-hit attack + * @param lastHit - `true` if this is the last hit in a multi-hit attack + * @param isProtected - `true` if the target is protected by effects such as Protect + * @param hitResult - The {@linkcode HitResult} of the attempted move + * @param firstTarget - `true` if {@linkcode target} is the first target hit by this strike of {@linkcode move} + * @returns a function intended to be passed into a `then()` call. + */ + protected applySuccessfulAttackEffects(user: Pokemon, target: Pokemon, firstHit : boolean, lastHit: boolean, isProtected : boolean, hitResult: HitResult, firstTarget: boolean) : () => Promise { + return () => executeIf(!isProtected, () => + this.applyOnHitEffects(user, target, firstHit, lastHit, firstTarget).then(() => + this.applyOnGetHitAbEffects(user, target, hitResult)).then(() => + applyPostAttackAbAttrs(PostAttackAbAttr, user, target, this.move.getMove(), hitResult)).then(() => { // Item Stealing Effects + + if (this.move.getMove() instanceof AttackMove) { + this.scene.applyModifiers(ContactHeldItemTransferChanceModifier, this.player, user, target); + } + }) + ); + } + + /** + * Handles checking for and applying Flinches + * @param user - The {@linkcode Pokemon} using this phase's invoked move + * @param target - {@linkcode Pokemon} the current target of this phase's invoked move + * @param dealsDamage - `true` if the attempted move successfully dealt damage + * @returns a function intended to be passed into a `then()` call. + */ + protected applyHeldItemFlinchCheck(user: Pokemon, target: Pokemon, dealsDamage: boolean) : () => void { + return () => { + if (dealsDamage && !target.hasAbilityWithAttr(IgnoreMoveEffectsAbAttr) && !this.move.getMove().hitsSubstitute(user, target)) { + const flinched = new BooleanHolder(false); + user.scene.applyModifiers(FlinchChanceModifier, user.isPlayer(), user, flinched); + if (flinched.value) { + target.addTag(BattlerTagType.FLINCHED, undefined, this.move.moveId, user.id); + } + } + }; + } + + /** + * Resolves whether this phase's invoked move hits the given target + * @param target - The {@linkcode Pokemon} targeted by the invoked move + * @returns `true` if the move hits the target + */ + public hitCheck(target: Pokemon): boolean { // Moves targeting the user and entry hazards can't miss if ([ MoveTarget.USER, MoveTarget.ENEMY_SIDE ].includes(this.move.getMove().moveTarget)) { return true; @@ -425,29 +565,29 @@ export class MoveEffectPhase extends PokemonPhase { return rand < (moveAccuracy * accuracyMultiplier); } - /** Returns the {@linkcode Pokemon} using this phase's invoked move */ - getUserPokemon(): Pokemon | undefined { + /** @returns The {@linkcode Pokemon} using this phase's invoked move */ + public getUserPokemon(): Pokemon | undefined { if (this.battlerIndex > BattlerIndex.ENEMY_2) { return this.scene.getPokemonById(this.battlerIndex) ?? undefined; } return (this.player ? this.scene.getPlayerField() : this.scene.getEnemyField())[this.fieldIndex]; } - /** Returns an array of all {@linkcode Pokemon} targeted by this phase's invoked move */ - getTargets(): Pokemon[] { + /** @returns An array of all {@linkcode Pokemon} targeted by this phase's invoked move */ + public getTargets(): Pokemon[] { return this.scene.getField(true).filter(p => this.targets.indexOf(p.getBattlerIndex()) > -1); } - /** Returns the first target of this phase's invoked move */ - getTarget(): Pokemon | undefined { + /** @returns The first target of this phase's invoked move */ + public getFirstTarget(): Pokemon | undefined { return this.getTargets()[0]; } /** * Removes the given {@linkcode Pokemon} from this phase's target list - * @param target {@linkcode Pokemon} the Pokemon to be removed + * @param target - The {@linkcode Pokemon} to be removed */ - removeTarget(target: Pokemon): void { + protected removeTarget(target: Pokemon): void { const targetIndex = this.targets.findIndex(ind => ind === target.getBattlerIndex()); if (targetIndex !== -1) { this.targets.splice(this.targets.findIndex(ind => ind === target.getBattlerIndex()), 1); @@ -459,23 +599,25 @@ export class MoveEffectPhase extends PokemonPhase { * @param target {@linkcode Pokemon} if defined, only stop subsequent * strikes against this Pokemon */ - stopMultiHit(target?: Pokemon): void { - /** If given a specific target, remove the target from subsequent strikes */ + public stopMultiHit(target?: Pokemon): void { + // If given a specific target, remove the target from subsequent strikes if (target) { this.removeTarget(target); } - /** - * If no target specified, or the specified target was the last of this move's - * targets, completely cancel all subsequent strikes. - */ + const user = this.getUserPokemon(); + if (!user) { + return; + } + // If no target specified, or the specified target was the last of this move's + // targets, completely cancel all subsequent strikes. if (!target || this.targets.length === 0 ) { - this.getUserPokemon()!.turnData.hitCount = 1; // TODO: is the bang correct here? - this.getUserPokemon()!.turnData.hitsLeft = 1; // TODO: is the bang correct here? + user.turnData.hitCount = 1; + user.turnData.hitsLeft = 1; } } - /** Returns a new MoveEffectPhase with the same properties as this phase */ - getNewHitPhase() { + /** @returns A new `MoveEffectPhase` with the same properties as this phase */ + protected getNewHitPhase(): MoveEffectPhase { return new MoveEffectPhase(this.scene, this.battlerIndex, this.targets, this.move); } } diff --git a/src/test/abilities/serene_grace.test.ts b/src/test/abilities/serene_grace.test.ts index 3155594c81d..ddca87496e9 100644 --- a/src/test/abilities/serene_grace.test.ts +++ b/src/test/abilities/serene_grace.test.ts @@ -57,7 +57,7 @@ describe("Abilities - Serene Grace", () => { const chance = new Utils.IntegerHolder(move.chance); console.log(move.chance + " Their ability is " + phase.getUserPokemon()!.getAbility().name); - applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getTarget(), false); + applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getFirstTarget(), false); expect(chance.value).toBe(30); }, 20000); @@ -83,7 +83,7 @@ describe("Abilities - Serene Grace", () => { expect(move.id).toBe(Moves.AIR_SLASH); const chance = new Utils.IntegerHolder(move.chance); - applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getTarget(), false); + applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getFirstTarget(), false); expect(chance.value).toBe(60); }, 20000); diff --git a/src/test/abilities/sheer_force.test.ts b/src/test/abilities/sheer_force.test.ts index a2600476d6d..63c81e9aafc 100644 --- a/src/test/abilities/sheer_force.test.ts +++ b/src/test/abilities/sheer_force.test.ts @@ -60,8 +60,8 @@ describe("Abilities - Sheer Force", () => { const power = new Utils.IntegerHolder(move.power); const chance = new Utils.IntegerHolder(move.chance); - applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getTarget(), false); - applyPreAttackAbAttrs(MovePowerBoostAbAttr, phase.getUserPokemon()!, phase.getTarget()!, move, false, power); + applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getFirstTarget(), false); + applyPreAttackAbAttrs(MovePowerBoostAbAttr, phase.getUserPokemon()!, phase.getFirstTarget()!, move, false, power); expect(chance.value).toBe(0); expect(power.value).toBe(move.power * 5461 / 4096); @@ -93,8 +93,8 @@ describe("Abilities - Sheer Force", () => { const power = new Utils.IntegerHolder(move.power); const chance = new Utils.IntegerHolder(move.chance); - applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getTarget(), false); - applyPreAttackAbAttrs(MovePowerBoostAbAttr, phase.getUserPokemon()!, phase.getTarget()!, move, false, power); + applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getFirstTarget(), false); + applyPreAttackAbAttrs(MovePowerBoostAbAttr, phase.getUserPokemon()!, phase.getFirstTarget()!, move, false, power); expect(chance.value).toBe(-1); expect(power.value).toBe(move.power); @@ -126,8 +126,8 @@ describe("Abilities - Sheer Force", () => { const power = new Utils.IntegerHolder(move.power); const chance = new Utils.IntegerHolder(move.chance); - applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getTarget(), false); - applyPreAttackAbAttrs(MovePowerBoostAbAttr, phase.getUserPokemon()!, phase.getTarget()!, move, false, power); + applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getFirstTarget(), false); + applyPreAttackAbAttrs(MovePowerBoostAbAttr, phase.getUserPokemon()!, phase.getFirstTarget()!, move, false, power); expect(chance.value).toBe(-1); expect(power.value).toBe(move.power); @@ -161,7 +161,7 @@ describe("Abilities - Sheer Force", () => { const power = new Utils.IntegerHolder(move.power); const chance = new Utils.IntegerHolder(move.chance); const user = phase.getUserPokemon()!; - const target = phase.getTarget()!; + const target = phase.getFirstTarget()!; const opponentType = target.getTypes()[0]; applyAbAttrs(MoveEffectChanceMultiplierAbAttr, user, null, false, chance, move, target, false); diff --git a/src/test/abilities/shield_dust.test.ts b/src/test/abilities/shield_dust.test.ts index 0f831fcf3fa..ccedf6873d7 100644 --- a/src/test/abilities/shield_dust.test.ts +++ b/src/test/abilities/shield_dust.test.ts @@ -57,8 +57,8 @@ describe("Abilities - Shield Dust", () => { expect(move.id).toBe(Moves.AIR_SLASH); const chance = new Utils.IntegerHolder(move.chance); - applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getTarget(), false); - applyPreDefendAbAttrs(IgnoreMoveEffectsAbAttr, phase.getTarget()!, phase.getUserPokemon()!, null, null, false, chance); + applyAbAttrs(MoveEffectChanceMultiplierAbAttr, phase.getUserPokemon()!, null, false, chance, move, phase.getFirstTarget(), false); + applyPreDefendAbAttrs(IgnoreMoveEffectsAbAttr, phase.getFirstTarget()!, phase.getUserPokemon()!, null, null, false, chance); expect(chance.value).toBe(0); }, 20000); diff --git a/src/test/moves/camouflage.test.ts b/src/test/moves/camouflage.test.ts new file mode 100644 index 00000000000..acf37635c47 --- /dev/null +++ b/src/test/moves/camouflage.test.ts @@ -0,0 +1,49 @@ +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { TerrainType } from "#app/data/terrain"; +import { Type } from "#app/data/type"; +import { BattlerIndex } from "#app/battle"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +describe("Moves - Camouflage", () => { + 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([ Moves.CAMOUFLAGE ]) + .ability(Abilities.BALL_FETCH) + .battleType("single") + .disableCrits() + .enemySpecies(Species.REGIELEKI) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.PSYCHIC_TERRAIN); + }); + + it("Camouflage should look at terrain first when selecting a type to change into", async () => { + await game.classicMode.startBattle([ Species.SHUCKLE ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + + game.move.select(Moves.CAMOUFLAGE); + await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); + await game.phaseInterceptor.to("BerryPhase"); + expect(game.scene.arena.getTerrainType()).toBe(TerrainType.PSYCHIC); + const pokemonType = playerPokemon.getTypes()[0]; + expect(pokemonType).toBe(Type.PSYCHIC); + }); +}); diff --git a/src/test/moves/dynamax_cannon.test.ts b/src/test/moves/dynamax_cannon.test.ts index 9dd48d3c94c..001f986bd52 100644 --- a/src/test/moves/dynamax_cannon.test.ts +++ b/src/test/moves/dynamax_cannon.test.ts @@ -81,7 +81,7 @@ describe("Moves - Dynamax Cannon", () => { const phase = game.scene.getCurrentPhase() as MoveEffectPhase; expect(phase.move.moveId).toBe(dynamaxCannon.id); // Force level cap to be 100 - vi.spyOn(phase.getTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); + vi.spyOn(phase.getFirstTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamagePhase, false); expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(120); }, 20000); @@ -98,7 +98,7 @@ describe("Moves - Dynamax Cannon", () => { const phase = game.scene.getCurrentPhase() as MoveEffectPhase; expect(phase.move.moveId).toBe(dynamaxCannon.id); // Force level cap to be 100 - vi.spyOn(phase.getTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); + vi.spyOn(phase.getFirstTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamagePhase, false); expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(140); }, 20000); @@ -115,7 +115,7 @@ describe("Moves - Dynamax Cannon", () => { const phase = game.scene.getCurrentPhase() as MoveEffectPhase; expect(phase.move.moveId).toBe(dynamaxCannon.id); // Force level cap to be 100 - vi.spyOn(phase.getTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); + vi.spyOn(phase.getFirstTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamagePhase, false); expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(160); }, 20000); @@ -132,7 +132,7 @@ describe("Moves - Dynamax Cannon", () => { const phase = game.scene.getCurrentPhase() as MoveEffectPhase; expect(phase.move.moveId).toBe(dynamaxCannon.id); // Force level cap to be 100 - vi.spyOn(phase.getTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); + vi.spyOn(phase.getFirstTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamagePhase, false); expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(180); }, 20000); @@ -149,7 +149,7 @@ describe("Moves - Dynamax Cannon", () => { const phase = game.scene.getCurrentPhase() as MoveEffectPhase; expect(phase.move.moveId).toBe(dynamaxCannon.id); // Force level cap to be 100 - vi.spyOn(phase.getTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); + vi.spyOn(phase.getFirstTarget()!.scene, "getMaxExpLevel").mockReturnValue(100); await game.phaseInterceptor.to(DamagePhase, false); expect(dynamaxCannon.calculateBattlePower).toHaveLastReturnedWith(200); }, 20000); diff --git a/src/test/moves/secret_power.test.ts b/src/test/moves/secret_power.test.ts index ff0b5ae8c24..09fe5faa50b 100644 --- a/src/test/moves/secret_power.test.ts +++ b/src/test/moves/secret_power.test.ts @@ -2,7 +2,7 @@ import { Abilities } from "#enums/abilities"; import { Biome } from "#enums/biome"; import { Moves } from "#enums/moves"; import { Stat } from "#enums/stat"; -import { allMoves, SecretPowerAttr } from "#app/data/move"; +import { allMoves } from "#app/data/move"; import { Species } from "#enums/species"; import GameManager from "#test/utils/gameManager"; import Phaser from "phaser"; @@ -11,6 +11,7 @@ import { StatusEffect } from "#enums/status-effect"; import { BattlerIndex } from "#app/battle"; import { ArenaTagType } from "#enums/arena-tag-type"; import { ArenaTagSide } from "#app/data/arena-tag"; +import { allAbilities, MoveEffectChanceMultiplierAbAttr } from "#app/data/ability"; describe("Moves - Secret Power", () => { let phaserGame: Phaser.Game; @@ -60,30 +61,38 @@ describe("Moves - Secret Power", () => { expect(enemyPokemon.getStatStage(Stat.SPATK)).toBe(-1); }); - it("the 'rainbow' effect of fire+water pledge does not double the chance of secret power's secondary effect", + it("Secret Power's effect chance is doubled by Serene Grace, but not by the 'rainbow' effect from Fire/Water Pledge", async () => { game.override .moveset([ Moves.FIRE_PLEDGE, Moves.WATER_PLEDGE, Moves.SECRET_POWER, Moves.SPLASH ]) + .ability(Abilities.SERENE_GRACE) .enemyMoveset([ Moves.SPLASH ]) .battleType("double"); await game.classicMode.startBattle([ Species.BLASTOISE, Species.CHARIZARD ]); - const secretPowerAttr = allMoves[Moves.SECRET_POWER].getAttrs(SecretPowerAttr)[0]; - vi.spyOn(secretPowerAttr, "getMoveChance"); + const sereneGraceAttr = allAbilities[Abilities.SERENE_GRACE].getAttrs(MoveEffectChanceMultiplierAbAttr)[0]; + vi.spyOn(sereneGraceAttr, "apply"); game.move.select(Moves.WATER_PLEDGE, 0, BattlerIndex.ENEMY); game.move.select(Moves.FIRE_PLEDGE, 1, BattlerIndex.ENEMY_2); await game.phaseInterceptor.to("TurnEndPhase"); - expect(game.scene.arena.getTagOnSide(ArenaTagType.WATER_FIRE_PLEDGE, ArenaTagSide.PLAYER)).toBeDefined(); + let rainbowEffect = game.scene.arena.getTagOnSide(ArenaTagType.WATER_FIRE_PLEDGE, ArenaTagSide.PLAYER); + expect(rainbowEffect).toBeDefined(); + + rainbowEffect = rainbowEffect!; + vi.spyOn(rainbowEffect, "apply"); game.move.select(Moves.SECRET_POWER, 0, BattlerIndex.ENEMY); game.move.select(Moves.SPLASH, 1); await game.phaseInterceptor.to("BerryPhase", false); - expect(secretPowerAttr.getMoveChance).toHaveLastReturnedWith(30); + expect(sereneGraceAttr.apply).toHaveBeenCalledOnce(); + expect(sereneGraceAttr.apply).toHaveLastReturnedWith(true); + + expect(rainbowEffect.apply).toHaveBeenCalledTimes(0); } ); }); diff --git a/src/test/moves/shed_tail.test.ts b/src/test/moves/shed_tail.test.ts index c4df6c574cb..4d761a8af24 100644 --- a/src/test/moves/shed_tail.test.ts +++ b/src/test/moves/shed_tail.test.ts @@ -1,4 +1,5 @@ import { SubstituteTag } from "#app/data/battler-tags"; +import { MoveResult } from "#app/field/pokemon"; import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; @@ -53,4 +54,18 @@ describe("Moves - Shed Tail", () => { expect(substituteTag).toBeDefined(); expect(substituteTag?.hp).toBe(Math.floor(magikarp.getMaxHp() / 4)); }); + + it("should fail if no ally is available to switch in", async () => { + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const magikarp = game.scene.getPlayerPokemon()!; + expect(game.scene.getParty().length).toBe(1); + + game.move.select(Moves.SHED_TAIL); + + await game.phaseInterceptor.to("TurnEndPhase", false); + + expect(magikarp.isOnField()).toBeTruthy(); + expect(magikarp.getLastXMoves()[0].result).toBe(MoveResult.FAIL); + }); }); diff --git a/src/test/utils/gameWrapper.ts b/src/test/utils/gameWrapper.ts index 48c0007118b..22517502a05 100644 --- a/src/test/utils/gameWrapper.ts +++ b/src/test/utils/gameWrapper.ts @@ -24,6 +24,7 @@ import GamepadPlugin = Phaser.Input.Gamepad.GamepadPlugin; import EventEmitter = Phaser.Events.EventEmitter; import UpdateList = Phaser.GameObjects.UpdateList; import { version } from "../../../package.json"; +import { MockTimedEventManager } from "./mocks/mockTimedEventManager"; Object.defineProperty(window, "localStorage", { value: mockLocalStorage(), @@ -232,6 +233,7 @@ export default class GameWrapper { this.scene.make = new MockGameObjectCreator(mockTextureManager); this.scene.time = new MockClock(this.scene); this.scene.remove = vi.fn(); // TODO: this should be stubbed differently + this.scene.eventManager = new MockTimedEventManager(); // Disable Timed Events } } diff --git a/src/test/utils/mocks/mockTimedEventManager.ts b/src/test/utils/mocks/mockTimedEventManager.ts new file mode 100644 index 00000000000..b44729996a7 --- /dev/null +++ b/src/test/utils/mocks/mockTimedEventManager.ts @@ -0,0 +1,17 @@ +import { TimedEventManager } from "#app/timed-event-manager"; + +/** Mock TimedEventManager so that ongoing events don't impact tests */ +export class MockTimedEventManager extends TimedEventManager { + override activeEvent() { + return undefined; + } + override isEventActive(): boolean { + return false; + } + override getFriendshipMultiplier(): number { + return 1; + } + override getShinyMultiplier(): number { + return 1; + } +}