From 0bd4d6c86bc85907c6194bc723f0220fb855f6ab Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Fri, 4 Oct 2024 13:20:37 -0700 Subject: [PATCH 1/5] [Move] Fully Implement the Pledge Moves (#4511) * Implement Fire/Grass Pledge combo * Add other Pledge combo effects (untested) * Fix missing enums * Pledge moves integration tests * Add turn order manipulation + more tests * Safeguarding against weird Instruct interactions * Update src/test/moves/pledge_moves.test.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Fix style issues * Delete arena-tag.json * Update package-lock.json * Use `instanceof` for all arg type inference * Add Pledge Move sleep test * Fix linting * Fix linting Apparently GitHub has a limit on how many errors it will show * Pledges now only bypass redirection from abilities --------- Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- package-lock.json | 1 + src/data/arena-tag.ts | 82 +++++++ src/data/move.ts | 203 ++++++++++++++++- src/enums/arena-tag-type.ts | 3 + src/field/pokemon.ts | 12 +- src/phases/move-phase.ts | 16 +- src/test/moves/pledge_moves.test.ts | 337 ++++++++++++++++++++++++++++ 7 files changed, 642 insertions(+), 12 deletions(-) create mode 100644 src/test/moves/pledge_moves.test.ts diff --git a/package-lock.json b/package-lock.json index f633d427d6dc..ee2708b38f51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "pokemon-rogue-battle", "version": "1.0.4", + "hasInstallScript": true, "dependencies": { "@material/material-color-utilities": "^0.2.7", "crypto-js": "^4.2.0", diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index abe443cdfa6a..b75d23b48d86 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -19,6 +19,7 @@ import { MoveEffectPhase } from "#app/phases/move-effect-phase"; import { PokemonHealPhase } from "#app/phases/pokemon-heal-phase"; import { ShowAbilityPhase } from "#app/phases/show-ability-phase"; import { StatStageChangePhase } from "#app/phases/stat-stage-change-phase"; +import { CommonAnimPhase } from "#app/phases/common-anim-phase"; export enum ArenaTagSide { BOTH, @@ -1025,6 +1026,81 @@ class ImprisonTag extends ArenaTrapTag { } } +/** + * Arena Tag implementing the "sea of fire" effect from the combination + * of {@link https://bulbapedia.bulbagarden.net/wiki/Fire_Pledge_(move) | Fire Pledge} + * and {@link https://bulbapedia.bulbagarden.net/wiki/Grass_Pledge_(move) | Grass Pledge}. + * Damages all non-Fire-type Pokemon on the given side of the field at the end + * of each turn for 4 turns. + */ +class FireGrassPledgeTag extends ArenaTag { + constructor(sourceId: number, side: ArenaTagSide) { + super(ArenaTagType.FIRE_GRASS_PLEDGE, 4, Moves.FIRE_PLEDGE, sourceId, side); + } + + override onAdd(arena: Arena): void { + // "A sea of fire enveloped your/the opposing team!" + arena.scene.queueMessage(i18next.t(`arenaTag:fireGrassPledgeOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`)); + } + + override lapse(arena: Arena): boolean { + const field: Pokemon[] = (this.side === ArenaTagSide.PLAYER) + ? arena.scene.getPlayerField() + : arena.scene.getEnemyField(); + + field.filter(pokemon => !pokemon.isOfType(Type.FIRE)).forEach(pokemon => { + // "{pokemonNameWithAffix} was hurt by the sea of fire!" + pokemon.scene.queueMessage(i18next.t("arenaTag:fireGrassPledgeLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); + // TODO: Replace this with a proper animation + pokemon.scene.unshiftPhase(new CommonAnimPhase(pokemon.scene, pokemon.getBattlerIndex(), pokemon.getBattlerIndex(), CommonAnim.MAGMA_STORM)); + pokemon.damageAndUpdate(Utils.toDmgValue(pokemon.getMaxHp() / 8)); + }); + + return super.lapse(arena); + } +} + +/** + * Arena Tag implementing the "rainbow" effect from the combination + * of {@link https://bulbapedia.bulbagarden.net/wiki/Water_Pledge_(move) | Water Pledge} + * and {@link https://bulbapedia.bulbagarden.net/wiki/Fire_Pledge_(move) | Fire Pledge}. + * Doubles the secondary effect chance of moves from Pokemon on the + * given side of the field for 4 turns. + */ +class WaterFirePledgeTag extends ArenaTag { + constructor(sourceId: number, side: ArenaTagSide) { + super(ArenaTagType.WATER_FIRE_PLEDGE, 4, Moves.WATER_PLEDGE, sourceId, side); + } + + override onAdd(arena: Arena): void { + // "A rainbow appeared in the sky on your/the opposing team's side!" + arena.scene.queueMessage(i18next.t(`arenaTag:waterFirePledgeOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`)); + } + + override apply(arena: Arena, args: any[]): boolean { + const moveChance = args[0] as Utils.NumberHolder; + moveChance.value *= 2; + return true; + } +} + +/** + * Arena Tag implementing the "swamp" effect from the combination + * of {@link https://bulbapedia.bulbagarden.net/wiki/Grass_Pledge_(move) | Grass Pledge} + * and {@link https://bulbapedia.bulbagarden.net/wiki/Water_Pledge_(move) | Water Pledge}. + * Quarters the Speed of Pokemon on the given side of the field for 4 turns. + */ +class GrassWaterPledgeTag extends ArenaTag { + constructor(sourceId: number, side: ArenaTagSide) { + super(ArenaTagType.GRASS_WATER_PLEDGE, 4, Moves.GRASS_PLEDGE, sourceId, side); + } + + override onAdd(arena: Arena): void { + // "A swamp enveloped your/the opposing team!" + arena.scene.queueMessage(i18next.t(`arenaTag:grassWaterPledgeOnAdd${this.side === ArenaTagSide.PLAYER ? "Player" : this.side === ArenaTagSide.ENEMY ? "Enemy" : ""}`)); + } +} + export function getArenaTag(tagType: ArenaTagType, turnCount: integer, sourceMove: Moves | undefined, sourceId: integer, targetIndex?: BattlerIndex, side: ArenaTagSide = ArenaTagSide.BOTH): ArenaTag | null { switch (tagType) { case ArenaTagType.MIST: @@ -1076,6 +1152,12 @@ export function getArenaTag(tagType: ArenaTagType, turnCount: integer, sourceMov return new SafeguardTag(turnCount, sourceId, side); case ArenaTagType.IMPRISON: return new ImprisonTag(sourceId, side); + case ArenaTagType.FIRE_GRASS_PLEDGE: + return new FireGrassPledgeTag(sourceId, side); + case ArenaTagType.WATER_FIRE_PLEDGE: + return new WaterFirePledgeTag(sourceId, side); + case ArenaTagType.GRASS_WATER_PLEDGE: + return new GrassWaterPledgeTag(sourceId, side); default: return null; } diff --git a/src/data/move.ts b/src/data/move.ts index 2225a457a425..62ac36b28adf 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -1010,7 +1010,14 @@ export class MoveEffectAttr extends MoveAttr { */ getMoveChance(user: Pokemon, target: Pokemon, move: Move, selfEffect?: Boolean, showAbility?: Boolean): integer { const moveChance = new Utils.NumberHolder(move.chance); + applyAbAttrs(MoveEffectChanceMultiplierAbAttr, user, null, false, moveChance, move, target, selfEffect, showAbility); + + if (!move.hasAttr(FlinchAttr) || moveChance.value <= move.chance) { + const userSide = user.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; + user.scene.arena.applyTagsForSide(ArenaTagType.WATER_FIRE_PLEDGE, userSide, moveChance); + } + if (!selfEffect) { applyPreDefendAbAttrs(IgnoreMoveEffectsAbAttr, target, user, null, null, false, moveChance); } @@ -2687,6 +2694,62 @@ export class DelayedAttackAttr extends OverrideMoveEffectAttr { } } +/** + * Attribute that cancels the associated move's effects when set to be combined with the user's ally's + * subsequent move this turn. Used for Grass Pledge, Water Pledge, and Fire Pledge. + * @extends OverrideMoveEffectAttr + */ +export class AwaitCombinedPledgeAttr extends OverrideMoveEffectAttr { + constructor() { + super(true); + } + /** + * If the user's ally is set to use a different move with this attribute, + * defer this move's effects for a combined move on the ally's turn. + * @param user the {@linkcode Pokemon} using this move + * @param target n/a + * @param move the {@linkcode Move} being used + * @param args + * - [0] a {@linkcode Utils.BooleanHolder} indicating whether the move's base + * effects should be overridden this turn. + * @returns `true` if base move effects were overridden; `false` otherwise + */ + override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (user.turnData.combiningPledge) { + // "The two moves have become one!\nIt's a combined move!" + user.scene.queueMessage(i18next.t("moveTriggers:combiningPledge")); + return false; + } + + const overridden = args[0] as Utils.BooleanHolder; + + const allyMovePhase = user.scene.findPhase((phase) => phase instanceof MovePhase && phase.pokemon.isPlayer() === user.isPlayer()); + if (allyMovePhase) { + const allyMove = allyMovePhase.move.getMove(); + if (allyMove !== move && allyMove.hasAttr(AwaitCombinedPledgeAttr)) { + [ user, allyMovePhase.pokemon ].forEach((p) => p.turnData.combiningPledge = move.id); + + // "{userPokemonName} is waiting for {allyPokemonName}'s move..." + user.scene.queueMessage(i18next.t("moveTriggers:awaitingPledge", { + userPokemonName: getPokemonNameWithAffix(user), + allyPokemonName: getPokemonNameWithAffix(allyMovePhase.pokemon) + })); + + // Move the ally's MovePhase (if needed) so that the ally moves next + const allyMovePhaseIndex = user.scene.phaseQueue.indexOf(allyMovePhase); + const firstMovePhaseIndex = user.scene.phaseQueue.findIndex((phase) => phase instanceof MovePhase); + if (allyMovePhaseIndex !== firstMovePhaseIndex) { + user.scene.prependToPhase(user.scene.phaseQueue.splice(allyMovePhaseIndex, 1)[0], MovePhase); + } + + overridden.value = true; + return true; + } + } + return false; + } +} + /** * Attribute used for moves that change stat stages * @param stats {@linkcode BattleStat} array of stats to be changed @@ -3762,6 +3825,45 @@ export class LastMoveDoublePowerAttr extends VariablePowerAttr { } } +/** + * Changes a Pledge move's power to 150 when combined with another unique Pledge + * move from an ally. + */ +export class CombinedPledgePowerAttr extends VariablePowerAttr { + override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + const power = args[0]; + if (!(power instanceof Utils.NumberHolder)) { + return false; + } + const combinedPledgeMove = user.turnData.combiningPledge; + + if (combinedPledgeMove && combinedPledgeMove !== move.id) { + power.value *= 150 / 80; + return true; + } + return false; + } +} + +/** + * Applies STAB to the given Pledge move if the move is part of a combined attack. + */ +export class CombinedPledgeStabBoostAttr extends MoveAttr { + override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + const stabMultiplier = args[0]; + if (!(stabMultiplier instanceof Utils.NumberHolder)) { + return false; + } + const combinedPledgeMove = user.turnData.combiningPledge; + + if (combinedPledgeMove && combinedPledgeMove !== move.id) { + stabMultiplier.value = 1.5; + return true; + } + return false; + } +} + export class VariableAtkAttr extends MoveAttr { constructor() { super(); @@ -4358,6 +4460,47 @@ export class MatchUserTypeAttr extends VariableMoveTypeAttr { } } +/** + * Changes the type of a Pledge move based on the Pledge move combined with it. + * @extends VariableMoveTypeAttr + */ +export class CombinedPledgeTypeAttr extends VariableMoveTypeAttr { + override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + const moveType = args[0]; + if (!(moveType instanceof Utils.NumberHolder)) { + return false; + } + + const combinedPledgeMove = user.turnData.combiningPledge; + if (!combinedPledgeMove) { + return false; + } + + switch (move.id) { + case Moves.FIRE_PLEDGE: + if (combinedPledgeMove === Moves.WATER_PLEDGE) { + moveType.value = Type.WATER; + return true; + } + return false; + case Moves.WATER_PLEDGE: + if (combinedPledgeMove === Moves.GRASS_PLEDGE) { + moveType.value = Type.GRASS; + return true; + } + return false; + case Moves.GRASS_PLEDGE: + if (combinedPledgeMove === Moves.FIRE_PLEDGE) { + moveType.value = Type.FIRE; + return true; + } + return false; + default: + return false; + } + } +} + export class VariableMoveTypeMultiplierAttr extends MoveAttr { apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { return false; @@ -4505,7 +4648,15 @@ export class TypelessAttr extends MoveAttr { } * Attribute used for moves which ignore redirection effects, and always target their original target, i.e. Snipe Shot * Bypasses Storm Drain, Follow Me, Ally Switch, and the like. */ -export class BypassRedirectAttr extends MoveAttr { } +export class BypassRedirectAttr extends MoveAttr { + /** `true` if this move only bypasses redirection from Abilities */ + public readonly abilitiesOnly: boolean; + + constructor(abilitiesOnly: boolean = false) { + super(); + this.abilitiesOnly = abilitiesOnly; + } +} export class FrenzyAttr extends MoveEffectAttr { constructor() { @@ -5196,6 +5347,32 @@ export class SwapArenaTagsAttr extends MoveEffectAttr { } } +/** + * Attribute that adds a secondary effect to the field when two unique Pledge moves + * are combined. The effect added varies based on the two Pledge moves combined. + */ +export class AddPledgeEffectAttr extends AddArenaTagAttr { + private readonly requiredPledge: Moves; + + constructor(tagType: ArenaTagType, requiredPledge: Moves, selfSideTarget: boolean = false) { + super(tagType, 4, false, selfSideTarget); + + this.requiredPledge = requiredPledge; + } + + override apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + // TODO: add support for `HIT` effect triggering in AddArenaTagAttr to remove the need for this check + if (user.getLastXMoves(1)[0].result !== MoveResult.SUCCESS) { + return false; + } + + if (user.turnData.combiningPledge === this.requiredPledge) { + return super.apply(user, target, move, args); + } + return false; + } +} + /** * Attribute used for Revival Blessing. * @extends MoveEffectAttr @@ -8341,11 +8518,29 @@ export function initMoves() { new AttackMove(Moves.INFERNO, Type.FIRE, MoveCategory.SPECIAL, 100, 50, 5, 100, 0, 5) .attr(StatusEffectAttr, StatusEffect.BURN), new AttackMove(Moves.WATER_PLEDGE, Type.WATER, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 5) - .partial(), + .attr(AwaitCombinedPledgeAttr) + .attr(CombinedPledgeTypeAttr) + .attr(CombinedPledgePowerAttr) + .attr(CombinedPledgeStabBoostAttr) + .attr(AddPledgeEffectAttr, ArenaTagType.WATER_FIRE_PLEDGE, Moves.FIRE_PLEDGE, true) + .attr(AddPledgeEffectAttr, ArenaTagType.GRASS_WATER_PLEDGE, Moves.GRASS_PLEDGE) + .attr(BypassRedirectAttr, true), new AttackMove(Moves.FIRE_PLEDGE, Type.FIRE, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 5) - .partial(), + .attr(AwaitCombinedPledgeAttr) + .attr(CombinedPledgeTypeAttr) + .attr(CombinedPledgePowerAttr) + .attr(CombinedPledgeStabBoostAttr) + .attr(AddPledgeEffectAttr, ArenaTagType.FIRE_GRASS_PLEDGE, Moves.GRASS_PLEDGE) + .attr(AddPledgeEffectAttr, ArenaTagType.WATER_FIRE_PLEDGE, Moves.WATER_PLEDGE, true) + .attr(BypassRedirectAttr, true), new AttackMove(Moves.GRASS_PLEDGE, Type.GRASS, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 5) - .partial(), + .attr(AwaitCombinedPledgeAttr) + .attr(CombinedPledgeTypeAttr) + .attr(CombinedPledgePowerAttr) + .attr(CombinedPledgeStabBoostAttr) + .attr(AddPledgeEffectAttr, ArenaTagType.GRASS_WATER_PLEDGE, Moves.WATER_PLEDGE) + .attr(AddPledgeEffectAttr, ArenaTagType.FIRE_GRASS_PLEDGE, Moves.FIRE_PLEDGE) + .attr(BypassRedirectAttr, true), new AttackMove(Moves.VOLT_SWITCH, Type.ELECTRIC, MoveCategory.SPECIAL, 70, 100, 20, -1, 0, 5) .attr(ForceSwitchOutAttr, true), new AttackMove(Moves.STRUGGLE_BUG, Type.BUG, MoveCategory.SPECIAL, 50, 100, 20, 100, 0, 5) diff --git a/src/enums/arena-tag-type.ts b/src/enums/arena-tag-type.ts index c484b2932f1d..0ab0d76e880e 100644 --- a/src/enums/arena-tag-type.ts +++ b/src/enums/arena-tag-type.ts @@ -25,4 +25,7 @@ export enum ArenaTagType { NO_CRIT = "NO_CRIT", IMPRISON = "IMPRISON", PLASMA_FISTS = "PLASMA_FISTS", + FIRE_GRASS_PLEDGE = "FIRE_GRASS_PLEDGE", + WATER_FIRE_PLEDGE = "WATER_FIRE_PLEDGE", + GRASS_WATER_PLEDGE = "GRASS_WATER_PLEDGE", } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 05567491a1a3..c2ef7d919b0c 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3,7 +3,7 @@ import BattleScene, { AnySound } from "#app/battle-scene"; import { Variant, VariantSet, variantColorCache } from "#app/data/variant"; import { variantData } from "#app/data/variant"; import BattleInfo, { PlayerBattleInfo, EnemyBattleInfo } from "#app/ui/battle-info"; -import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatStagesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatStageChangeAttr, RechargeAttr, ChargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, OneHitKOAccuracyAttr, RespectAttackTypeImmunityAttr, MoveTarget } from "#app/data/move"; +import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatStagesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatStageChangeAttr, RechargeAttr, ChargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, OneHitKOAccuracyAttr, RespectAttackTypeImmunityAttr, MoveTarget, CombinedPledgeStabBoostAttr } from "#app/data/move"; import { default as PokemonSpecies, PokemonSpeciesForm, getFusedSpeciesName, getPokemonSpecies, getPokemonSpeciesForm } from "#app/data/pokemon-species"; import { getStarterValueFriendshipCap, speciesStarterCosts } from "#app/data/balance/starters"; import { starterPassiveAbilities } from "#app/data/balance/passives"; @@ -924,11 +924,13 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } break; case Stat.SPD: - // Check both the player and enemy to see if Tailwind should be multiplying the speed of the Pokemon - if ((this.isPlayer() && this.scene.arena.getTagOnSide(ArenaTagType.TAILWIND, ArenaTagSide.PLAYER)) - || (!this.isPlayer() && this.scene.arena.getTagOnSide(ArenaTagType.TAILWIND, ArenaTagSide.ENEMY))) { + const side = this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY; + if (this.scene.arena.getTagOnSide(ArenaTagType.TAILWIND, side)) { ret *= 2; } + if (this.scene.arena.getTagOnSide(ArenaTagType.GRASS_WATER_PLEDGE, side)) { + ret >>= 2; + } if (this.getTag(BattlerTagType.SLOW_START)) { ret >>= 1; @@ -2562,6 +2564,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (matchesSourceType) { stabMultiplier.value += 0.5; } + applyMoveAttrs(CombinedPledgeStabBoostAttr, source, this, move, stabMultiplier); if (sourceTeraType !== Type.UNKNOWN && sourceTeraType === moveType) { stabMultiplier.value += 0.5; } @@ -5041,6 +5044,7 @@ export class PokemonTurnData { public statStagesIncreased: boolean = false; public statStagesDecreased: boolean = false; public moveEffectiveness: TypeDamageMultiplier | null = null; + public combiningPledge?: Moves; } export enum AiType { diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 807f194bad52..6272358aa85a 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -331,22 +331,30 @@ export class MovePhase extends BattlePhase { // check move redirection abilities of every pokemon *except* the user. this.scene.getField(true).filter(p => p !== this.pokemon).forEach(p => applyAbAttrs(RedirectMoveAbAttr, p, null, false, this.move.moveId, redirectTarget)); + /** `true` if an Ability is responsible for redirecting the move to another target; `false` otherwise */ + let redirectedByAbility = (currentTarget !== redirectTarget.value); + // check for center-of-attention tags (note that this will override redirect abilities) this.pokemon.getOpponents().forEach(p => { - const redirectTag = p.getTag(CenterOfAttentionTag) as CenterOfAttentionTag; + const redirectTag = p.getTag(CenterOfAttentionTag); // TODO: don't hardcode this interaction. // Handle interaction between the rage powder center-of-attention tag and moves used by grass types/overcoat-havers (which are immune to RP's redirect) if (redirectTag && (!redirectTag.powder || (!this.pokemon.isOfType(Type.GRASS) && !this.pokemon.hasAbility(Abilities.OVERCOAT)))) { redirectTarget.value = p.getBattlerIndex(); + redirectedByAbility = false; } }); if (currentTarget !== redirectTarget.value) { - if (this.move.getMove().hasAttr(BypassRedirectAttr)) { - redirectTarget.value = currentTarget; + const bypassRedirectAttrs = this.move.getMove().getAttrs(BypassRedirectAttr); + bypassRedirectAttrs.forEach((attr) => { + if (!attr.abilitiesOnly || redirectedByAbility) { + redirectTarget.value = currentTarget; + } + }); - } else if (this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr)) { + if (this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr)) { redirectTarget.value = currentTarget; this.scene.unshiftPhase(new ShowAbilityPhase(this.scene, this.pokemon.getBattlerIndex(), this.pokemon.getPassiveAbility().hasAttr(BlockRedirectAbAttr))); } diff --git a/src/test/moves/pledge_moves.test.ts b/src/test/moves/pledge_moves.test.ts new file mode 100644 index 000000000000..93fcf57cc605 --- /dev/null +++ b/src/test/moves/pledge_moves.test.ts @@ -0,0 +1,337 @@ +import { BattlerIndex } from "#app/battle"; +import { allAbilities } from "#app/data/ability"; +import { ArenaTagSide } from "#app/data/arena-tag"; +import { allMoves, FlinchAttr } from "#app/data/move"; +import { Type } from "#app/data/type"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import { Stat } from "#enums/stat"; +import { toDmgValue } from "#app/utils"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest"; + +describe("Moves - Pledge Moves", () => { + 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 + .battleType("double") + .startingLevel(100) + .moveset([ Moves.FIRE_PLEDGE, Moves.GRASS_PLEDGE, Moves.WATER_PLEDGE, Moves.SPLASH ]) + .enemySpecies(Species.SNORLAX) + .enemyLevel(100) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it( + "Fire Pledge - should be an 80-power Fire-type attack outside of combination", + async () => { + await game.classicMode.startBattle([ Species.BLASTOISE, Species.CHARIZARD ]); + + const firePledge = allMoves[Moves.FIRE_PLEDGE]; + vi.spyOn(firePledge, "calculateBattlePower"); + + const playerPokemon = game.scene.getPlayerField(); + vi.spyOn(playerPokemon[0], "getMoveType"); + + game.move.select(Moves.FIRE_PLEDGE, 0, BattlerIndex.ENEMY); + game.move.select(Moves.SPLASH, 1); + + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2 ]); + await game.phaseInterceptor.to("MoveEndPhase", false); + + expect(firePledge.calculateBattlePower).toHaveLastReturnedWith(80); + expect(playerPokemon[0].getMoveType).toHaveLastReturnedWith(Type.FIRE); + } + ); + + it( + "Fire Pledge - should not combine with an ally using Fire Pledge", + async () => { + await game.classicMode.startBattle([ Species.BLASTOISE, Species.CHARIZARD ]); + + const firePledge = allMoves[Moves.FIRE_PLEDGE]; + vi.spyOn(firePledge, "calculateBattlePower"); + + const playerPokemon = game.scene.getPlayerField(); + playerPokemon.forEach(p => vi.spyOn(p, "getMoveType")); + + const enemyPokemon = game.scene.getEnemyField(); + + game.move.select(Moves.FIRE_PLEDGE, 0, BattlerIndex.ENEMY); + game.move.select(Moves.FIRE_PLEDGE, 0, BattlerIndex.ENEMY_2); + + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2 ]); + + await game.phaseInterceptor.to("MoveEndPhase"); + expect(firePledge.calculateBattlePower).toHaveLastReturnedWith(80); + expect(playerPokemon[0].getMoveType).toHaveLastReturnedWith(Type.FIRE); + + await game.phaseInterceptor.to("BerryPhase", false); + expect(firePledge.calculateBattlePower).toHaveLastReturnedWith(80); + expect(playerPokemon[1].getMoveType).toHaveLastReturnedWith(Type.FIRE); + + enemyPokemon.forEach(p => expect(p.hp).toBeLessThan(p.getMaxHp())); + } + ); + + it( + "Fire Pledge - should not combine with an enemy's Pledge move", + async () => { + game.override + .battleType("single") + .enemyMoveset(Moves.GRASS_PLEDGE); + + await game.classicMode.startBattle([ Species.CHARIZARD ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.FIRE_PLEDGE); + + await game.toNextTurn(); + + // Neither Pokemon should defer their move's effects as they would + // if they combined moves, so both should be damaged. + expect(playerPokemon.hp).toBeLessThan(playerPokemon.getMaxHp()); + expect(enemyPokemon.hp).toBeLessThan(enemyPokemon.getMaxHp()); + expect(game.scene.arena.getTag(ArenaTagType.FIRE_GRASS_PLEDGE)).toBeUndefined(); + } + ); + + it( + "Grass Pledge - should combine with Fire Pledge to form a 150-power Fire-type attack that creates a 'sea of fire'", + async () => { + await game.classicMode.startBattle([ Species.CHARIZARD, Species.BLASTOISE ]); + + const grassPledge = allMoves[Moves.GRASS_PLEDGE]; + vi.spyOn(grassPledge, "calculateBattlePower"); + + const playerPokemon = game.scene.getPlayerField(); + const enemyPokemon = game.scene.getEnemyField(); + + vi.spyOn(playerPokemon[1], "getMoveType"); + const baseDmgMock = vi.spyOn(enemyPokemon[0], "getBaseDamage"); + + game.move.select(Moves.FIRE_PLEDGE, 0, BattlerIndex.ENEMY_2); + game.move.select(Moves.GRASS_PLEDGE, 1, BattlerIndex.ENEMY); + + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2 ]); + // advance to the end of PLAYER_2's move this turn + for (let i = 0; i < 2; i++) { + await game.phaseInterceptor.to("MoveEndPhase"); + } + expect(playerPokemon[1].getMoveType).toHaveLastReturnedWith(Type.FIRE); + expect(grassPledge.calculateBattlePower).toHaveLastReturnedWith(150); + + const baseDmg = baseDmgMock.mock.results[baseDmgMock.mock.results.length - 1].value; + expect(enemyPokemon[0].getMaxHp() - enemyPokemon[0].hp).toBe(toDmgValue(baseDmg * 1.5)); + expect(enemyPokemon[1].hp).toBe(enemyPokemon[1].getMaxHp()); // PLAYER should not have attacked + expect(game.scene.arena.getTagOnSide(ArenaTagType.FIRE_GRASS_PLEDGE, ArenaTagSide.ENEMY)).toBeDefined(); + + const enemyStartingHp = enemyPokemon.map(p => p.hp); + await game.toNextTurn(); + enemyPokemon.forEach((p, i) => expect(enemyStartingHp[i] - p.hp).toBe(toDmgValue(p.getMaxHp() / 8))); + } + ); + + it( + "Fire Pledge - should combine with Water Pledge to form a 150-power Water-type attack that creates a 'rainbow'", + async () => { + game.override.moveset([ Moves.FIRE_PLEDGE, Moves.WATER_PLEDGE, Moves.FIERY_DANCE, Moves.SPLASH ]); + + await game.classicMode.startBattle([ Species.BLASTOISE, Species.VENUSAUR ]); + + const firePledge = allMoves[Moves.FIRE_PLEDGE]; + vi.spyOn(firePledge, "calculateBattlePower"); + + const playerPokemon = game.scene.getPlayerField(); + const enemyPokemon = game.scene.getEnemyField(); + + vi.spyOn(playerPokemon[1], "getMoveType"); + + game.move.select(Moves.WATER_PLEDGE, 0, BattlerIndex.ENEMY_2); + game.move.select(Moves.FIRE_PLEDGE, 1, BattlerIndex.ENEMY); + + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2 ]); + // advance to the end of PLAYER_2's move this turn + for (let i = 0; i < 2; i++) { + await game.phaseInterceptor.to("MoveEndPhase"); + } + expect(playerPokemon[1].getMoveType).toHaveLastReturnedWith(Type.WATER); + expect(firePledge.calculateBattlePower).toHaveLastReturnedWith(150); + expect(enemyPokemon[1].hp).toBe(enemyPokemon[1].getMaxHp()); // PLAYER should not have attacked + expect(game.scene.arena.getTagOnSide(ArenaTagType.WATER_FIRE_PLEDGE, ArenaTagSide.PLAYER)).toBeDefined(); + + await game.toNextTurn(); + + game.move.select(Moves.FIERY_DANCE, 0, BattlerIndex.ENEMY_2); + game.move.select(Moves.SPLASH, 1); + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2 ]); + await game.phaseInterceptor.to("MoveEndPhase"); + + // Rainbow effect should increase Fiery Dance's chance of raising Sp. Atk to 100% + expect(playerPokemon[0].getStatStage(Stat.SPATK)).toBe(1); + } + ); + + it( + "Water Pledge - should combine with Grass Pledge to form a 150-power Grass-type attack that creates a 'swamp'", + async () => { + await game.classicMode.startBattle([ Species.BLASTOISE, Species.CHARIZARD ]); + + const waterPledge = allMoves[Moves.WATER_PLEDGE]; + vi.spyOn(waterPledge, "calculateBattlePower"); + + const playerPokemon = game.scene.getPlayerField(); + const enemyPokemon = game.scene.getEnemyField(); + const enemyStartingSpd = enemyPokemon.map(p => p.getEffectiveStat(Stat.SPD)); + + vi.spyOn(playerPokemon[1], "getMoveType"); + + game.move.select(Moves.GRASS_PLEDGE, 0, BattlerIndex.ENEMY_2); + game.move.select(Moves.WATER_PLEDGE, 1, BattlerIndex.ENEMY); + + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2 ]); + // advance to the end of PLAYER_2's move this turn + for (let i = 0; i < 2; i++) { + await game.phaseInterceptor.to("MoveEndPhase"); + } + + expect(playerPokemon[1].getMoveType).toHaveLastReturnedWith(Type.GRASS); + expect(waterPledge.calculateBattlePower).toHaveLastReturnedWith(150); + expect(enemyPokemon[1].hp).toBe(enemyPokemon[1].getMaxHp()); + + expect(game.scene.arena.getTagOnSide(ArenaTagType.GRASS_WATER_PLEDGE, ArenaTagSide.ENEMY)).toBeDefined(); + enemyPokemon.forEach((p, i) => expect(p.getEffectiveStat(Stat.SPD)).toBe(Math.floor(enemyStartingSpd[i] / 4))); + } + ); + + it( + "Pledge Moves - should alter turn order when used in combination", + async () => { + await game.classicMode.startBattle([ Species.CHARIZARD, Species.BLASTOISE ]); + + const enemyPokemon = game.scene.getEnemyField(); + + game.move.select(Moves.WATER_PLEDGE, 0, BattlerIndex.ENEMY); + game.move.select(Moves.FIRE_PLEDGE, 1, BattlerIndex.ENEMY_2); + + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER_2 ]); + // PLAYER_2 should act with a combined move immediately after PLAYER as the second move in the turn + for (let i = 0; i < 2; i++) { + await game.phaseInterceptor.to("MoveEndPhase"); + } + expect(enemyPokemon[0].hp).toBe(enemyPokemon[0].getMaxHp()); + expect(enemyPokemon[1].hp).toBeLessThan(enemyPokemon[1].getMaxHp()); + } + ); + + it( + "Pledge Moves - 'rainbow' effect should not stack with Serene Grace when applied to flinching moves", + async () => { + game.override + .ability(Abilities.SERENE_GRACE) + .moveset([ Moves.FIRE_PLEDGE, Moves.WATER_PLEDGE, Moves.IRON_HEAD, Moves.SPLASH ]); + + await game.classicMode.startBattle([ Species.BLASTOISE, Species.CHARIZARD ]); + + const ironHeadFlinchAttr = allMoves[Moves.IRON_HEAD].getAttrs(FlinchAttr)[0]; + vi.spyOn(ironHeadFlinchAttr, "getMoveChance"); + + 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(); + + game.move.select(Moves.IRON_HEAD, 0, BattlerIndex.ENEMY); + game.move.select(Moves.SPLASH, 1); + + await game.phaseInterceptor.to("BerryPhase", false); + + expect(ironHeadFlinchAttr.getMoveChance).toHaveLastReturnedWith(60); + } + ); + + it( + "Pledge Moves - should have no effect when the second ally's move is cancelled", + async () => { + game.override + .enemyMoveset([ Moves.SPLASH, Moves.SPORE ]); + + await game.classicMode.startBattle([ Species.BLASTOISE, Species.CHARIZARD ]); + + const enemyPokemon = game.scene.getEnemyField(); + + game.move.select(Moves.FIRE_PLEDGE, 0, BattlerIndex.ENEMY); + game.move.select(Moves.GRASS_PLEDGE, 1, BattlerIndex.ENEMY_2); + + await game.forceEnemyMove(Moves.SPORE, BattlerIndex.PLAYER_2); + await game.forceEnemyMove(Moves.SPLASH); + + await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2 ]); + + await game.phaseInterceptor.to("BerryPhase", false); + + enemyPokemon.forEach((p) => expect(p.hp).toBe(p.getMaxHp())); + } + ); + + it( + "Pledge Moves - should ignore redirection from another Pokemon's Storm Drain", + async () => { + await game.classicMode.startBattle([ Species.BLASTOISE, Species.CHARIZARD ]); + + const enemyPokemon = game.scene.getEnemyField(); + vi.spyOn(enemyPokemon[1], "getAbility").mockReturnValue(allAbilities[Abilities.STORM_DRAIN]); + + game.move.select(Moves.WATER_PLEDGE, 0, BattlerIndex.ENEMY); + game.move.select(Moves.SPLASH, 1); + + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2 ]); + + await game.phaseInterceptor.to("MoveEndPhase", false); + + expect(enemyPokemon[0].hp).toBeLessThan(enemyPokemon[0].getMaxHp()); + expect(enemyPokemon[1].getStatStage(Stat.SPATK)).toBe(0); + } + ); + + it( + "Pledge Moves - should not ignore redirection from another Pokemon's Follow Me", + async () => { + game.override.enemyMoveset([ Moves.FOLLOW_ME, Moves.SPLASH ]); + await game.classicMode.startBattle([ Species.BLASTOISE, Species.CHARIZARD ]); + + game.move.select(Moves.WATER_PLEDGE, 0, BattlerIndex.ENEMY); + game.move.select(Moves.SPLASH, 1); + + await game.forceEnemyMove(Moves.SPLASH); + await game.forceEnemyMove(Moves.FOLLOW_ME); + + await game.phaseInterceptor.to("BerryPhase", false); + + const enemyPokemon = game.scene.getEnemyField(); + expect(enemyPokemon[0].hp).toBe(enemyPokemon[0].getMaxHp()); + expect(enemyPokemon[1].hp).toBeLessThan(enemyPokemon[1].getMaxHp()); + } + ); +}); From 27537286b925d3b47d454df8f0d1e5a4154797e8 Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Fri, 4 Oct 2024 13:24:52 -0700 Subject: [PATCH 2/5] [Move] Implement Electrify (#4569) * Implement Electrify * ESLint * Fix docs --- src/data/battler-tags.ts | 17 ++++++++ src/data/move.ts | 2 +- src/enums/battler-tag-type.ts | 1 + src/field/pokemon.ts | 3 ++ src/test/moves/electrify.test.ts | 69 ++++++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/test/moves/electrify.test.ts diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 6cb33dca3068..a54a8c5f519a 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -2310,6 +2310,21 @@ export class TarShotTag extends BattlerTag { } } +/** + * Battler Tag implementing the type-changing effect of {@link https://bulbapedia.bulbagarden.net/wiki/Electrify_(move) | Electrify}. + * While this tag is in effect, the afflicted Pokemon's moves are changed to Electric type. + */ +export class ElectrifiedTag extends BattlerTag { + constructor() { + super(BattlerTagType.ELECTRIFIED, BattlerTagLapseType.TURN_END, 1, Moves.ELECTRIFY); + } + + override onAdd(pokemon: Pokemon): void { + // "{pokemonNameWithAffix}'s moves have been electrified!" + pokemon.scene.queueMessage(i18next.t("battlerTags:electrifiedOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); + } +} + /** * Battler Tag that keeps track of how many times the user has Autotomized * Each count of Autotomization reduces the weight by 100kg @@ -2811,6 +2826,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source return new GulpMissileTag(tagType, sourceMove); case BattlerTagType.TAR_SHOT: return new TarShotTag(); + case BattlerTagType.ELECTRIFIED: + return new ElectrifiedTag(); case BattlerTagType.THROAT_CHOPPED: return new ThroatChoppedTag(); case BattlerTagType.GORILLA_TACTICS: diff --git a/src/data/move.ts b/src/data/move.ts index 62ac36b28adf..8095b5a6013e 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -8732,7 +8732,7 @@ export function initMoves() { .attr(TerrainChangeAttr, TerrainType.MISTY) .target(MoveTarget.BOTH_SIDES), new StatusMove(Moves.ELECTRIFY, Type.ELECTRIC, -1, 20, -1, 0, 6) - .unimplemented(), + .attr(AddBattlerTagAttr, BattlerTagType.ELECTRIFIED, false, true), new AttackMove(Moves.PLAY_ROUGH, Type.FAIRY, MoveCategory.PHYSICAL, 90, 90, 10, 10, 0, 6) .attr(StatStageChangeAttr, [ Stat.ATK ], -1), new AttackMove(Moves.FAIRY_WIND, Type.FAIRY, MoveCategory.SPECIAL, 40, 100, 30, -1, 0, 6) diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index ccd6e9fe314f..43c849a78e0b 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -85,4 +85,5 @@ export enum BattlerTagType { TAUNT = "TAUNT", IMPRISON = "IMPRISON", SYRUP_BOMB = "SYRUP_BOMB", + ELECTRIFIED = "ELECTRIFIED", } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index c2ef7d919b0c..94fa050a7bc1 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1528,6 +1528,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { applyPreAttackAbAttrs(MoveTypeChangeAbAttr, this, null, move, simulated, moveTypeHolder); this.scene.arena.applyTags(ArenaTagType.PLASMA_FISTS, moveTypeHolder); + if (this.getTag(BattlerTagType.ELECTRIFIED)) { + moveTypeHolder.value = Type.ELECTRIC; + } return moveTypeHolder.value as Type; } diff --git a/src/test/moves/electrify.test.ts b/src/test/moves/electrify.test.ts new file mode 100644 index 000000000000..5d15a825688a --- /dev/null +++ b/src/test/moves/electrify.test.ts @@ -0,0 +1,69 @@ +import { BattlerIndex } from "#app/battle"; +import { Type } from "#app/data/type"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest"; + +describe("Moves - Electrify", () => { + 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.ELECTRIFY) + .battleType("single") + .startingLevel(100) + .enemySpecies(Species.SNORLAX) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.TACKLE) + .enemyLevel(100); + }); + + it("should convert attacks to Electric type", async () => { + await game.classicMode.startBattle([ Species.EXCADRILL ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getEnemyPokemon()!; + vi.spyOn(enemyPokemon, "getMoveType"); + + game.move.select(Moves.ELECTRIFY); + + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + + await game.phaseInterceptor.to("BerryPhase", false); + expect(enemyPokemon.getMoveType).toHaveLastReturnedWith(Type.ELECTRIC); + expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp()); + }); + + it("should override type changes from abilities", async () => { + game.override.enemyAbility(Abilities.PIXILATE); + + await game.classicMode.startBattle([ Species.EXCADRILL ]); + + const playerPokemon = game.scene.getPlayerPokemon()!; + const enemyPokemon = game.scene.getPlayerPokemon()!; + vi.spyOn(enemyPokemon, "getMoveType"); + + game.move.select(Moves.ELECTRIFY); + + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + + await game.phaseInterceptor.to("BerryPhase", false); + expect(enemyPokemon.getMoveType).toHaveLastReturnedWith(Type.ELECTRIC); + expect(playerPokemon.hp).toBe(playerPokemon.getMaxHp()); + }); +}); From d36245650197f7c6302456b16b02b28cf01853c5 Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Fri, 4 Oct 2024 13:29:20 -0700 Subject: [PATCH 3/5] [P2] Diamond Storm should only trigger once when hitting multiple pokemon (#4544) * Diamond Storm should only trigger once when hitting multiple pokemon * Also fix Clangorous Soulblaze just in case * Fix linting * Fix linting Oops missed this one --- src/data/move.ts | 4 +-- src/test/moves/diamond_storm.test.ts | 46 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/test/moves/diamond_storm.test.ts diff --git a/src/data/move.ts b/src/data/move.ts index 8095b5a6013e..01b300cbae41 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -8756,7 +8756,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) + .attr(StatStageChangeAttr, [ Stat.DEF ], 2, true, undefined, undefined, undefined, undefined, true) .makesContact(false) .target(MoveTarget.ALL_NEAR_ENEMIES), new AttackMove(Moves.STEAM_ERUPTION, Type.WATER, MoveCategory.SPECIAL, 110, 95, 5, 30, 0, 6) @@ -9183,7 +9183,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) + .attr(StatStageChangeAttr, [ Stat.ATK, Stat.DEF, Stat.SPATK, Stat.SPDEF, Stat.SPD ], 1, true, undefined, undefined, undefined, undefined, true) .soundBased() .target(MoveTarget.ALL_NEAR_ENEMIES) .partial() diff --git a/src/test/moves/diamond_storm.test.ts b/src/test/moves/diamond_storm.test.ts new file mode 100644 index 000000000000..6e5be2a790d4 --- /dev/null +++ b/src/test/moves/diamond_storm.test.ts @@ -0,0 +1,46 @@ +import { allMoves } from "#app/data/move"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { Stat } from "#enums/stat"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Moves - Diamond Storm", () => { + 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.DIAMOND_STORM ]) + .battleType("single") + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should only increase defense once even if hitting 2 pokemon", async () => { + game.override.battleType("double"); + const diamondStorm = allMoves[Moves.DIAMOND_STORM]; + vi.spyOn(diamondStorm, "chance", "get").mockReturnValue(100); + vi.spyOn(diamondStorm, "accuracy", "get").mockReturnValue(100); + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.DIAMOND_STORM); + await game.phaseInterceptor.to("BerryPhase"); + + expect(game.scene.getPlayerPokemon()!.getStatStage(Stat.DEF)).toBe(2); + }); +}); From 1947472f1c5015de733552ec907e71497550e177 Mon Sep 17 00:00:00 2001 From: MokaStitcher <54149968+MokaStitcher@users.noreply.github.com> Date: Fri, 4 Oct 2024 22:47:12 +0200 Subject: [PATCH 4/5] [P3] Fix start button cursor not being cleared properly in starter select (#4558) --- src/ui/starter-select-ui-handler.ts | 71 +++++++++++++++++------------ 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/src/ui/starter-select-ui-handler.ts b/src/ui/starter-select-ui-handler.ts index 5cc70abf1439..98a563301e41 100644 --- a/src/ui/starter-select-ui-handler.ts +++ b/src/ui/starter-select-ui-handler.ts @@ -308,13 +308,7 @@ export default class StarterSelectUiHandler extends MessageUiHandler { private starterIconsCursorObj: Phaser.GameObjects.Image; private valueLimitLabel: Phaser.GameObjects.Text; private startCursorObj: Phaser.GameObjects.NineSlice; - // private starterValueLabels: Phaser.GameObjects.Text[]; - // private shinyIcons: Phaser.GameObjects.Image[][]; - // private hiddenAbilityIcons: Phaser.GameObjects.Image[]; - // private classicWinIcons: Phaser.GameObjects.Image[]; - // private candyUpgradeIcon: Phaser.GameObjects.Image[]; - // private candyUpgradeOverlayIcon: Phaser.GameObjects.Image[]; - // + private iconAnimHandler: PokemonIconAnimHandler; //variables to keep track of the dynamically rendered list of instruction prompts for starter select @@ -1316,12 +1310,12 @@ export default class StarterSelectUiHandler extends MessageUiHandler { } break; case Button.UP: + // UP from start button: go to pokemon in team if any, otherwise filter this.startCursorObj.setVisible(false); if (this.starterSpecies.length > 0) { this.starterIconsCursorIndex = this.starterSpecies.length - 1; this.moveStarterIconsCursor(this.starterIconsCursorIndex); } else { - // up from start button with no Pokemon in the team > go to filter this.startCursorObj.setVisible(false); this.filterBarCursor = Math.max(1, this.filterBar.numFilters - 1); this.setFilterMode(true); @@ -1329,29 +1323,27 @@ export default class StarterSelectUiHandler extends MessageUiHandler { success = true; break; case Button.DOWN: + // DOWN from start button: Go to filters this.startCursorObj.setVisible(false); - if (this.starterSpecies.length > 0) { - this.starterIconsCursorIndex = 0; - this.moveStarterIconsCursor(this.starterIconsCursorIndex); - } else { - // down from start button with no Pokemon in the team > go to filter - this.startCursorObj.setVisible(false); - this.filterBarCursor = Math.max(1, this.filterBar.numFilters - 1); - this.setFilterMode(true); - } + this.filterBarCursor = Math.max(1, this.filterBar.numFilters - 1); + this.setFilterMode(true); success = true; break; case Button.LEFT: - this.startCursorObj.setVisible(false); - this.cursorObj.setVisible(true); - success = this.setCursor(onScreenFirstIndex + (onScreenNumberOfRows - 1) * 9 + 8); // set last column - success = true; + if (numberOfStarters > 0) { + this.startCursorObj.setVisible(false); + this.cursorObj.setVisible(true); + this.setCursor(onScreenFirstIndex + (onScreenNumberOfRows - 1) * 9 + 8); // set last column + success = true; + } break; case Button.RIGHT: - this.startCursorObj.setVisible(false); - this.cursorObj.setVisible(true); - success = this.setCursor(onScreenFirstIndex + (onScreenNumberOfRows - 1) * 9); // set first column - success = true; + if (numberOfStarters > 0) { + this.startCursorObj.setVisible(false); + this.cursorObj.setVisible(true); + this.setCursor(onScreenFirstIndex + (onScreenNumberOfRows - 1) * 9); // set first column + success = true; + } break; } } else if (this.filterMode) { @@ -1373,7 +1365,12 @@ export default class StarterSelectUiHandler extends MessageUiHandler { case Button.UP: if (this.filterBar.openDropDown) { success = this.filterBar.decDropDownCursor(); - // else if there is filtered starters + } else if (this.filterBarCursor === this.filterBar.numFilters - 1 && this.starterSpecies.length > 0) { + // UP from the last filter, move to start button + this.setFilterMode(false); + this.cursorObj.setVisible(false); + this.startCursorObj.setVisible(true); + success = true; } else if (numberOfStarters > 0) { // UP from filter bar to bottom of Pokemon list this.setFilterMode(false); @@ -1392,6 +1389,13 @@ export default class StarterSelectUiHandler extends MessageUiHandler { case Button.DOWN: if (this.filterBar.openDropDown) { success = this.filterBar.incDropDownCursor(); + } else if (this.filterBarCursor === this.filterBar.numFilters - 1 && this.starterSpecies.length > 0) { + // DOWN from the last filter, move to Pokemon in party if any + this.setFilterMode(false); + this.cursorObj.setVisible(false); + this.starterIconsCursorIndex = 0; + this.moveStarterIconsCursor(this.starterIconsCursorIndex); + success = true; } else if (numberOfStarters > 0) { // DOWN from filter bar to top of Pokemon list this.setFilterMode(false); @@ -2656,9 +2660,6 @@ export default class StarterSelectUiHandler extends MessageUiHandler { this.pokemonShinyIcon.setTint(tint); this.setSpecies(species); this.updateInstructions(); - } else { - console.warn("Species is undefined for cursor position", cursor); - this.setFilterMode(true); } } @@ -3326,6 +3327,18 @@ export default class StarterSelectUiHandler extends MessageUiHandler { } } this.moveStarterIconsCursor(this.starterIconsCursorIndex); + } else if (this.startCursorObj.visible && this.starterSpecies.length === 0) { + // On the start button and no more Pokemon in party + this.startCursorObj.setVisible(false); + if (this.filteredStarterContainers.length > 0) { + // Back to the first Pokemon if there is one + this.cursorObj.setVisible(true); + this.setCursor(0 + this.scrollCursor * 9); + } else { + // Back to filters + this.filterBarCursor = Math.max(1, this.filterBar.numFilters - 1); + this.setFilterMode(true); + } } this.tryUpdateValue(); From c99df9712a383dac44e0782a47c3a91225dfbcf0 Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Fri, 4 Oct 2024 14:23:20 -0700 Subject: [PATCH 5/5] [Move] Implement Ion Deluge (#4579) --- src/data/arena-tag.ts | 15 ++++++++------- src/data/move.ts | 6 +++--- src/enums/arena-tag-type.ts | 2 +- src/field/pokemon.ts | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index b75d23b48d86..6407e139a71b 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -513,15 +513,16 @@ class WaterSportTag extends WeakenMoveTypeTag { } /** - * Arena Tag class for the secondary effect of {@link https://bulbapedia.bulbagarden.net/wiki/Plasma_Fists_(move) | Plasma Fists}. + * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Ion_Deluge_(move) | Ion Deluge} + * and the secondary effect of {@link https://bulbapedia.bulbagarden.net/wiki/Plasma_Fists_(move) | Plasma Fists}. * Converts Normal-type moves to Electric type for the rest of the turn. */ -export class PlasmaFistsTag extends ArenaTag { - constructor() { - super(ArenaTagType.PLASMA_FISTS, 1, Moves.PLASMA_FISTS); +export class IonDelugeTag extends ArenaTag { + constructor(sourceMove?: Moves) { + super(ArenaTagType.ION_DELUGE, 1, sourceMove); } - /** Queues Plasma Fists' on-add message */ + /** Queues an on-add message */ onAdd(arena: Arena): void { arena.scene.queueMessage(i18next.t("arenaTag:plasmaFistsOnAdd")); } @@ -1119,8 +1120,8 @@ export function getArenaTag(tagType: ArenaTagType, turnCount: integer, sourceMov return new MudSportTag(turnCount, sourceId); case ArenaTagType.WATER_SPORT: return new WaterSportTag(turnCount, sourceId); - case ArenaTagType.PLASMA_FISTS: - return new PlasmaFistsTag(); + case ArenaTagType.ION_DELUGE: + return new IonDelugeTag(sourceMove); case ArenaTagType.SPIKES: return new SpikesTag(sourceId, side); case ArenaTagType.TOXIC_SPIKES: diff --git a/src/data/move.ts b/src/data/move.ts index 01b300cbae41..f795d2653363 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -8688,8 +8688,8 @@ export function initMoves() { .attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], -1) .soundBased(), new StatusMove(Moves.ION_DELUGE, Type.ELECTRIC, -1, 25, -1, 1, 6) - .target(MoveTarget.BOTH_SIDES) - .unimplemented(), + .attr(AddArenaTagAttr, ArenaTagType.ION_DELUGE) + .target(MoveTarget.BOTH_SIDES), new AttackMove(Moves.PARABOLIC_CHARGE, Type.ELECTRIC, MoveCategory.SPECIAL, 65, 100, 20, -1, 0, 6) .attr(HitHealAttr) .target(MoveTarget.ALL_NEAR_OTHERS) @@ -9158,7 +9158,7 @@ export function initMoves() { .attr(HalfSacrificialAttr) .target(MoveTarget.ALL_NEAR_OTHERS), new AttackMove(Moves.PLASMA_FISTS, Type.ELECTRIC, MoveCategory.PHYSICAL, 100, 100, 15, -1, 0, 7) - .attr(AddArenaTagAttr, ArenaTagType.PLASMA_FISTS, 1) + .attr(AddArenaTagAttr, ArenaTagType.ION_DELUGE, 1) .punchingMove(), new AttackMove(Moves.PHOTON_GEYSER, Type.PSYCHIC, MoveCategory.SPECIAL, 100, 100, 5, -1, 0, 7) .attr(PhotonGeyserCategoryAttr) diff --git a/src/enums/arena-tag-type.ts b/src/enums/arena-tag-type.ts index 0ab0d76e880e..c73f4ec2ae58 100644 --- a/src/enums/arena-tag-type.ts +++ b/src/enums/arena-tag-type.ts @@ -24,7 +24,7 @@ export enum ArenaTagType { SAFEGUARD = "SAFEGUARD", NO_CRIT = "NO_CRIT", IMPRISON = "IMPRISON", - PLASMA_FISTS = "PLASMA_FISTS", + ION_DELUGE = "ION_DELUGE", FIRE_GRASS_PLEDGE = "FIRE_GRASS_PLEDGE", WATER_FIRE_PLEDGE = "WATER_FIRE_PLEDGE", GRASS_WATER_PLEDGE = "GRASS_WATER_PLEDGE", diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 94fa050a7bc1..d6f73e1b5bc0 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1527,7 +1527,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { applyMoveAttrs(VariableMoveTypeAttr, this, null, move, moveTypeHolder); applyPreAttackAbAttrs(MoveTypeChangeAbAttr, this, null, move, simulated, moveTypeHolder); - this.scene.arena.applyTags(ArenaTagType.PLASMA_FISTS, moveTypeHolder); + this.scene.arena.applyTags(ArenaTagType.ION_DELUGE, moveTypeHolder); if (this.getTag(BattlerTagType.ELECTRIFIED)) { moveTypeHolder.value = Type.ELECTRIC; }