diff --git a/src/data/ability.ts b/src/data/ability.ts index 6dce4b4a5b9..56e41087d66 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -1702,7 +1702,18 @@ export class PostIntimidateStatChangeAbAttr extends AbAttr { } } +/** + * Base class for defining all {@linkcode Ability} Attributes post summon + * @see {@linkcode applyPostSummon()} + */ export class PostSummonAbAttr extends AbAttr { + /** + * Applies ability post summon (after switching in) + * @param pokemon {@linkcode Pokemon} with this ability + * @param passive Whether this ability is a passive + * @param args Set of unique arguments needed by this attribute + * @returns true if application of the ability succeeds + */ applyPostSummon(pokemon: Pokemon, passive: boolean, args: any[]): boolean | Promise { return false; } @@ -1974,7 +1985,11 @@ export class PostSummonFormChangeAbAttr extends PostSummonAbAttr { } } -export class TraceAbAttr extends PostSummonAbAttr { +/** Attempts to copy a pokemon's ability */ +export class PostSummonCopyAbilityAbAttr extends PostSummonAbAttr { + private targetName: string; + private targetAbilityName: string; + applyPostSummon(pokemon: Pokemon, passive: boolean, args: any[]): boolean { const targets = pokemon.getOpponents(); if (!targets.length) { @@ -1988,18 +2003,53 @@ export class TraceAbAttr extends PostSummonAbAttr { target = targets[0]; } - // Wonder Guard is normally uncopiable so has the attribute, but trace specifically can copy it - if (target.getAbility().hasAttr(UncopiableAbilityAbAttr) && target.getAbility().id !== Abilities.WONDER_GUARD) { + if ( + target.getAbility().hasAttr(UncopiableAbilityAbAttr) && + // Wonder Guard is normally uncopiable so has the attribute, but Trace specifically can copy it + !(pokemon.hasAbility(Abilities.TRACE) && target.getAbility().id === Abilities.WONDER_GUARD) + ) { return false; } + this.targetName = target.name; + this.targetAbilityName = allAbilities[target.getAbility().id].name; pokemon.summonData.ability = target.getAbility().id; - - pokemon.scene.queueMessage(getPokemonMessage(pokemon, ` traced ${target.name}'s\n${allAbilities[target.getAbility().id].name}!`)); setAbilityRevealed(target); + pokemon.updateInfo(); return true; } + + getTriggerMessage(pokemon: Pokemon, abilityName: string, ...args: any[]): string { + return getPokemonMessage(pokemon, ` copied ${this.targetName}'s\n${this.targetAbilityName}!`); + } +} + + +/** Attempt to copy the stat changes on an ally pokemon */ +export class PostSummonCopyAllyStatsAbAttr extends PostSummonAbAttr { + private allyName: string; + + applyPostSummon(pokemon: Pokemon, passive: boolean, args: any[]): boolean { + if (!pokemon.scene.currentBattle.double) { + return false; + } + + const ally = pokemon.getAlly(); + if (!ally || ally.summonData.battleStats.every((change) => change === 0)) { + return false; + } + + this.allyName = ally.name; + pokemon.summonData.battleStats = ally.summonData.battleStats; + pokemon.updateInfo(); + + return true; + } + + getTriggerMessage(pokemon: Pokemon, abilityName: string, ...args: any[]): string { + return getPokemonMessage(pokemon, ` copied ${this.allyName}'s stat changes!`); + } } export class PostSummonTransformAbAttr extends PostSummonAbAttr { @@ -4086,7 +4136,7 @@ export function initAbilities() { .attr(DoubleBattleChanceAbAttr) .ignorable(), new Ability(Abilities.TRACE, 3) - .attr(TraceAbAttr) + .attr(PostSummonCopyAbilityAbAttr) .attr(UncopiableAbilityAbAttr), new Ability(Abilities.HUGE_POWER, 3) .attr(BattleStatMultiplierAbAttr, BattleStat.ATK, 2), @@ -4941,7 +4991,7 @@ export function initAbilities() { .attr(VariableMovePowerBoostAbAttr, (user, target, move) => 1 + 0.1 * Math.min(user.isPlayer() ? user.scene.currentBattle.playerFaints : user.scene.currentBattle.enemyFaints, 5)) .partial(), new Ability(Abilities.COSTAR, 9) - .unimplemented(), + .attr(PostSummonCopyAllyStatsAbAttr), new Ability(Abilities.TOXIC_DEBRIS, 9) .attr(PostDefendApplyArenaTrapTagAbAttr, (target, user, move) => move.category === MoveCategory.PHYSICAL, ArenaTagType.TOXIC_SPIKES) .bypassFaint(), diff --git a/src/test/abilities/costar.test.ts b/src/test/abilities/costar.test.ts new file mode 100644 index 00000000000..1b7eb3f7b90 --- /dev/null +++ b/src/test/abilities/costar.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import GameManager from "../utils/gameManager"; +import Phaser from "phaser"; +import * as Overrides from "#app/overrides"; +import { BattleStat } from "#app/data/battle-stat.js"; +import { CommandPhase, MessagePhase } from "#app/phases.js"; +import { getMovePosition } from "../utils/gameManagerUtils"; +import { Abilities } from "#app/enums/abilities.js"; +import { Moves } from "#app/enums/moves.js"; +import { Species } from "#app/enums/species.js"; + +const TIMEOUT = 20 * 1000; + +describe("Abilities - COSTAR", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + vi.spyOn(Overrides, "DOUBLE_BATTLE_OVERRIDE", "get").mockReturnValue(true); + vi.spyOn(Overrides, "ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.COSTAR); + vi.spyOn(Overrides, "MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.NASTY_PLOT, Moves.CURSE]); + vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue([Moves.SPLASH, Moves.SPLASH, Moves.SPLASH, Moves.SPLASH]); + }); + + + test( + "ability copies positive stat changes", + async () => { + await game.startBattle([Species.MAGIKARP, Species.MAGIKARP, Species.FLAMIGO]); + + let [leftPokemon, rightPokemon] = game.scene.getPlayerField(); + expect(leftPokemon).not.toBe(undefined); + expect(rightPokemon).not.toBe(undefined); + + game.doAttack(getMovePosition(game.scene, 0, Moves.NASTY_PLOT)); + await game.phaseInterceptor.to(CommandPhase); + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + await game.toNextTurn(); + + expect(leftPokemon.summonData.battleStats[BattleStat.SPATK]).toBe(+2); + expect(rightPokemon.summonData.battleStats[BattleStat.SPATK]).toBe(0); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + await game.phaseInterceptor.to(CommandPhase); + game.doSwitchPokemon(2); + await game.phaseInterceptor.to(MessagePhase); + + [leftPokemon, rightPokemon] = game.scene.getPlayerField(); + expect(leftPokemon.summonData.battleStats[BattleStat.SPATK]).toBe(+2); + expect(rightPokemon.summonData.battleStats[BattleStat.SPATK]).toBe(+2); + }, + TIMEOUT, + ); + + test( + "ability copies negative stat changes", + async () => { + vi.spyOn(Overrides, "OPP_ABILITY_OVERRIDE", "get").mockReturnValue(Abilities.INTIMIDATE); + + await game.startBattle([Species.MAGIKARP, Species.MAGIKARP, Species.FLAMIGO]); + + let [leftPokemon, rightPokemon] = game.scene.getPlayerField(); + expect(leftPokemon).not.toBe(undefined); + expect(rightPokemon).not.toBe(undefined); + + expect(leftPokemon.summonData.battleStats[BattleStat.ATK]).toBe(-2); + expect(leftPokemon.summonData.battleStats[BattleStat.ATK]).toBe(-2); + + game.doAttack(getMovePosition(game.scene, 0, Moves.SPLASH)); + await game.phaseInterceptor.to(CommandPhase); + game.doSwitchPokemon(2); + await game.phaseInterceptor.to(MessagePhase); + + [leftPokemon, rightPokemon] = game.scene.getPlayerField(); + expect(leftPokemon.summonData.battleStats[BattleStat.ATK]).toBe(-2); + expect(rightPokemon.summonData.battleStats[BattleStat.ATK]).toBe(-2); + }, + TIMEOUT, + ); +});