diff --git a/src/data/move.ts b/src/data/move.ts index 2ffa8a36de9..95d306a61ba 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -6037,6 +6037,57 @@ export class DestinyBondAttr extends MoveEffectAttr { } } +/** + * Attribute to apply a battler tag to the target if they have had their stats boosted this turn. + * @extends AddBattlerTagAttr + */ +export class AddBattlerTagIfBoostedAttr extends AddBattlerTagAttr { + constructor(tag: BattlerTagType) { + super(tag, false, false, 2, 5); + } + + /** + * @param user {@linkcode Pokemon} using this move + * @param target {@linkcode Pokemon} target of this move + * @param move {@linkcode Move} being used + * @param {any[]} args N/A + * @returns true + */ + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (target.turnData.battleStatsIncreased) { + super.apply(user, target, move, args); + } + return true; + } +} + +/** + * Attribute to apply a status effect to the target if they have had their stats boosted this turn. + * @extends MoveEffectAttr + */ +export class StatusIfBoostedAttr extends MoveEffectAttr { + public effect: StatusEffect; + + constructor(effect: StatusEffect) { + super(true, MoveEffectTrigger.HIT); + this.effect = effect; + } + + /** + * @param user {@linkcode Pokemon} using this move + * @param target {@linkcode Pokemon} target of this move + * @param move {@linkcode Move} N/A + * @param {any[]} args N/A + * @returns true + */ + apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { + if (target.turnData.battleStatsIncreased) { + target.trySetStatus(this.effect, true, user); + } + return true; + } +} + export class LastResortAttr extends MoveAttr { getCondition(): MoveConditionFunc { return (user: Pokemon, target: Pokemon, move: Move) => { @@ -8694,10 +8745,10 @@ export function initMoves() { new AttackMove(Moves.SKITTER_SMACK, Type.BUG, MoveCategory.PHYSICAL, 70, 90, 10, 100, 0, 8) .attr(StatChangeAttr, BattleStat.SPATK, -1), new AttackMove(Moves.BURNING_JEALOUSY, Type.FIRE, MoveCategory.SPECIAL, 70, 100, 5, 100, 0, 8) - .target(MoveTarget.ALL_NEAR_ENEMIES) - .partial(), + .attr(StatusIfBoostedAttr, StatusEffect.BURN) + .target(MoveTarget.ALL_NEAR_ENEMIES), new AttackMove(Moves.LASH_OUT, Type.DARK, MoveCategory.PHYSICAL, 75, 100, 5, -1, 0, 8) - .partial(), + .attr(MovePowerMultiplierAttr, (user, target, move) => user.turnData.battleStatsDecreased ? 2 : 1), new AttackMove(Moves.POLTERGEIST, Type.GHOST, MoveCategory.PHYSICAL, 110, 90, 5, -1, 0, 8) .attr(AttackedByItemAttr) .makesContact(false), @@ -9146,8 +9197,8 @@ export function initMoves() { .attr(AddBattlerTagAttr, BattlerTagType.DRAGON_CHEER, false, true) .target(MoveTarget.NEAR_ALLY), new AttackMove(Moves.ALLURING_VOICE, Type.FAIRY, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 9) - .soundBased() - .partial(), + .attr(AddBattlerTagIfBoostedAttr, BattlerTagType.CONFUSED) + .soundBased(), new AttackMove(Moves.TEMPER_FLARE, Type.FIRE, MoveCategory.PHYSICAL, 75, 100, 10, -1, 0, 9) .attr(MovePowerMultiplierAttr, (user, target, move) => user.getLastXMoves(2)[1]?.result === MoveResult.MISS || user.getLastXMoves(2)[1]?.result === MoveResult.FAIL ? 2 : 1), new AttackMove(Moves.SUPERCELL_SLAM, Type.ELECTRIC, MoveCategory.PHYSICAL, 100, 95, 15, -1, 0, 9) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 00d5f5d3dd2..b1fcd512e35 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -4286,7 +4286,7 @@ export interface TurnMove { targets?: BattlerIndex[]; result: MoveResult; virtual?: boolean; - turn?: integer; + turn?: number; } export interface QueuedMove { @@ -4298,17 +4298,17 @@ export interface QueuedMove { export interface AttackMoveResult { move: Moves; result: DamageResult; - damage: integer; + damage: number; critical: boolean; - sourceId: integer; + sourceId: number; sourceBattlerIndex: BattlerIndex; } export class PokemonSummonData { - public battleStats: integer[] = [ 0, 0, 0, 0, 0, 0, 0 ]; + public battleStats: number[] = [ 0, 0, 0, 0, 0, 0, 0 ]; public moveQueue: QueuedMove[] = []; public disabledMove: Moves = Moves.NONE; - public disabledTurns: integer = 0; + public disabledTurns: number = 0; public tags: BattlerTag[] = []; public abilitySuppressed: boolean = false; public abilitiesApplied: Abilities[] = []; @@ -4318,14 +4318,14 @@ export class PokemonSummonData { public ability: Abilities = Abilities.NONE; public gender: Gender; public fusionGender: Gender; - public stats: integer[]; + public stats: number[]; public moveset: (PokemonMove | null)[]; // If not initialized this value will not be populated from save data. public types: Type[] = []; } export class PokemonBattleData { - public hitCount: integer = 0; + public hitCount: number = 0; public endured: boolean = false; public berriesEaten: BerryType[] = []; public abilitiesApplied: Abilities[] = []; @@ -4334,21 +4334,23 @@ export class PokemonBattleData { export class PokemonBattleSummonData { /** The number of turns the pokemon has passed since entering the battle */ - public turnCount: integer = 1; + public turnCount: number = 1; /** The list of moves the pokemon has used since entering the battle */ public moveHistory: TurnMove[] = []; } export class PokemonTurnData { - public flinched: boolean; - public acted: boolean; - public hitCount: integer; - public hitsLeft: integer; - public damageDealt: integer = 0; - public currDamageDealt: integer = 0; - public damageTaken: integer = 0; + public flinched: boolean = false; + public acted: boolean = false; + public hitCount: number; + public hitsLeft: number; + public damageDealt: number = 0; + public currDamageDealt: number = 0; + public damageTaken: number = 0; public attacksReceived: AttackMoveResult[] = []; public order: number; + public battleStatsIncreased: boolean = false; + public battleStatsDecreased: boolean = false; } export enum AiType { diff --git a/src/phases/stat-change-phase.ts b/src/phases/stat-change-phase.ts index 856d0a33eea..3116c49e8ef 100644 --- a/src/phases/stat-change-phase.ts +++ b/src/phases/stat-change-phase.ts @@ -1,14 +1,14 @@ -import BattleScene from "#app/battle-scene.js"; -import { BattlerIndex } from "#app/battle.js"; -import { applyPreStatChangeAbAttrs, ProtectStatAbAttr, applyAbAttrs, StatChangeMultiplierAbAttr, StatChangeCopyAbAttr, applyPostStatChangeAbAttrs, PostStatChangeAbAttr } from "#app/data/ability.js"; -import { MistTag, ArenaTagSide } from "#app/data/arena-tag.js"; -import { BattleStat, getBattleStatName, getBattleStatLevelChangeDescription } from "#app/data/battle-stat.js"; -import Pokemon from "#app/field/pokemon.js"; -import { getPokemonNameWithAffix } from "#app/messages.js"; -import { PokemonResetNegativeStatStageModifier } from "#app/modifier/modifier.js"; -import { handleTutorial, Tutorial } from "#app/tutorial.js"; +import { BattlerIndex } from "#app/battle"; +import BattleScene from "#app/battle-scene"; +import { applyAbAttrs, applyPostStatChangeAbAttrs, applyPreStatChangeAbAttrs, PostStatChangeAbAttr, ProtectStatAbAttr, StatChangeCopyAbAttr, StatChangeMultiplierAbAttr } from "#app/data/ability"; +import { ArenaTagSide, MistTag } from "#app/data/arena-tag"; +import { BattleStat, getBattleStatLevelChangeDescription, getBattleStatName } from "#app/data/battle-stat"; +import Pokemon from "#app/field/pokemon"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { PokemonResetNegativeStatStageModifier } from "#app/modifier/modifier"; +import { handleTutorial, Tutorial } from "#app/tutorial"; +import * as Utils from "#app/utils"; import i18next from "i18next"; -import * as Utils from "#app/utils.js"; import { PokemonPhase } from "./pokemon-phase"; export type StatChangeCallback = (target: Pokemon | null, changed: BattleStat[], relativeChanges: number[]) => void; @@ -72,7 +72,7 @@ export class StatChangePhase extends PokemonPhase { } const battleStats = this.getPokemon().summonData.battleStats; - const relLevels = filteredStats.map(stat => (levels.value >= 1 ? Math.min(battleStats![stat] + levels.value, 6) : Math.max(battleStats![stat] + levels.value, -6)) - battleStats![stat]); + const relLevels = filteredStats.map(stat => (levels.value >= 1 ? Math.min(battleStats[stat] + levels.value, 6) : Math.max(battleStats[stat] + levels.value, -6)) - battleStats[stat]); this.onChange && this.onChange(this.getPokemon(), filteredStats, relLevels); @@ -85,6 +85,20 @@ export class StatChangePhase extends PokemonPhase { } for (const stat of filteredStats) { + if (levels.value > 0 && pokemon.summonData.battleStats[stat] < 6) { + if (!pokemon.turnData) { + // Temporary fix for missing turn data struct on turn 1 + pokemon.resetTurnData(); + } + pokemon.turnData.battleStatsIncreased = true; + } else if (levels.value < 0 && pokemon.summonData.battleStats[stat] > -6) { + if (!pokemon.turnData) { + // Temporary fix for missing turn data struct on turn 1 + pokemon.resetTurnData(); + } + pokemon.turnData.battleStatsDecreased = true; + } + pokemon.summonData.battleStats[stat] = Math.max(Math.min(pokemon.summonData.battleStats[stat] + levels.value, 6), -6); } diff --git a/src/test/moves/alluring_voice.test.ts b/src/test/moves/alluring_voice.test.ts new file mode 100644 index 00000000000..e6ece39524a --- /dev/null +++ b/src/test/moves/alluring_voice.test.ts @@ -0,0 +1,54 @@ +import { BattlerIndex } from "#app/battle"; +import { Abilities } from "#app/enums/abilities"; +import { BattlerTagType } from "#app/enums/battler-tag-type"; +import { BerryPhase } from "#app/phases/berry-phase"; +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, expect, it } from "vitest"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Alluring Voice", () => { + 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("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.ICE_SCALES) + .enemyMoveset(Array(4).fill(Moves.HOWL)) + .startingLevel(10) + .enemyLevel(10) + .starterSpecies(Species.FEEBAS) + .ability(Abilities.BALL_FETCH) + .moveset([Moves.ALLURING_VOICE]); + + }); + + it("should confuse the opponent if their stats were raised", async () => { + await game.classicMode.startBattle(); + + const enemy = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.ALLURING_VOICE); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.phaseInterceptor.to(BerryPhase); + + expect(enemy.getTag(BattlerTagType.CONFUSED)?.tagType).toBe("CONFUSED"); + }, TIMEOUT); +}); diff --git a/src/test/moves/burning_jealousy.test.ts b/src/test/moves/burning_jealousy.test.ts new file mode 100644 index 00000000000..2281fe74acb --- /dev/null +++ b/src/test/moves/burning_jealousy.test.ts @@ -0,0 +1,103 @@ +import { BattlerIndex } from "#app/battle"; +import { allMoves } from "#app/data/move"; +import { Abilities } from "#app/enums/abilities"; +import { StatusEffect } from "#app/enums/status-effect"; +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, expect, it, vi } from "vitest"; +import { SPLASH_ONLY } from "../utils/testUtils"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Burning Jealousy", () => { + 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("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.ICE_SCALES) + .enemyMoveset(Array(4).fill(Moves.HOWL)) + .startingLevel(10) + .enemyLevel(10) + .starterSpecies(Species.FEEBAS) + .ability(Abilities.BALL_FETCH) + .moveset([Moves.BURNING_JEALOUSY, Moves.GROWL]); + + }); + + it("should burn the opponent if their stats were raised", async () => { + await game.classicMode.startBattle(); + + const enemy = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.BURNING_JEALOUSY); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.phaseInterceptor.to("BerryPhase"); + + expect(enemy.status?.effect).toBe(StatusEffect.BURN); + }, TIMEOUT); + + it("should still burn the opponent if their stats were both raised and lowered in the same turn", async () => { + game.override + .starterSpecies(0) + .battleType("double"); + await game.classicMode.startBattle([Species.FEEBAS, Species.ABRA]); + + const enemy = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.BURNING_JEALOUSY); + game.move.select(Moves.GROWL, 1); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER_2, BattlerIndex.PLAYER, BattlerIndex.ENEMY_2]); + await game.phaseInterceptor.to("BerryPhase"); + + expect(enemy.status?.effect).toBe(StatusEffect.BURN); + }, TIMEOUT); + + it("should ignore stats raised by imposter", async () => { + game.override + .enemySpecies(Species.DITTO) + .enemyAbility(Abilities.IMPOSTER) + .enemyMoveset(SPLASH_ONLY); + await game.classicMode.startBattle(); + + const enemy = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.BURNING_JEALOUSY); + await game.phaseInterceptor.to("BerryPhase"); + + expect(enemy.status?.effect).toBeUndefined(); + }, TIMEOUT); + + it.skip("should ignore weakness policy", async () => { // TODO: Make this test if WP is implemented + await game.classicMode.startBattle(); + }, TIMEOUT); + + it("should be boosted by Sheer Force even if opponent didn't raise stats", async () => { + game.override + .ability(Abilities.SHEER_FORCE) + .enemyMoveset(SPLASH_ONLY); + vi.spyOn(allMoves[Moves.BURNING_JEALOUSY], "calculateBattlePower"); + await game.classicMode.startBattle(); + + game.move.select(Moves.BURNING_JEALOUSY); + await game.phaseInterceptor.to("BerryPhase"); + + expect(allMoves[Moves.BURNING_JEALOUSY].calculateBattlePower).toHaveReturnedWith(allMoves[Moves.BURNING_JEALOUSY].power * 5461 / 4096); + }, TIMEOUT); +}); diff --git a/src/test/moves/lash_out.test.ts b/src/test/moves/lash_out.test.ts new file mode 100644 index 00000000000..78f4b712cf0 --- /dev/null +++ b/src/test/moves/lash_out.test.ts @@ -0,0 +1,52 @@ +import { BattlerIndex } from "#app/battle"; +import { allMoves } from "#app/data/move"; +import { Abilities } from "#app/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, expect, it, vi } from "vitest"; + +const TIMEOUT = 20 * 1000; + +describe("Moves - Lash Out", () => { + 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("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.FUR_COAT) + .enemyMoveset(Array(4).fill(Moves.GROWL)) + .startingLevel(10) + .enemyLevel(10) + .starterSpecies(Species.FEEBAS) + .ability(Abilities.BALL_FETCH) + .moveset([Moves.LASH_OUT]); + + }); + + it("should deal double damage if the user's stats were lowered this turn", async () => { + vi.spyOn(allMoves[Moves.LASH_OUT], "calculateBattlePower"); + await game.classicMode.startBattle(); + + game.move.select(Moves.LASH_OUT); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.phaseInterceptor.to("BerryPhase"); + + expect(allMoves[Moves.LASH_OUT].calculateBattlePower).toHaveReturnedWith(150); + }, TIMEOUT); +});