From 3bbe01b288fc4f04246f5269ae62c21cdd135d98 Mon Sep 17 00:00:00 2001 From: Dmitriy K Date: Sat, 22 Jun 2024 11:35:25 -0400 Subject: [PATCH] [Ability] Implement Costar Ability (#1218) * Add unthaw to moves that are missing it Add unthaw to all damaging fire moves Add Status Effect overrides for easier testing clean up comments and readd status cure before fainting * implement Costar ability, refactor TraceAbAttr to be more generic * format code, clean up comments * Revert "Add unthaw to moves that are missing it" This reverts commit 89494fa0c8633c90d42457ff1f125ebebd7153e5. * clean up comments, remove unused call * ability now copies negatives changes as well * separate PostSummonCopy into two different classes * small refactor of copy ability attrs * add costar to test suite * remove abstract declaration from comments * remove broken import statement * actually fix broken imports --- src/data/ability.ts | 64 +++++++++++++++++++--- src/test/abilities/costar.test.ts | 91 +++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 src/test/abilities/costar.test.ts 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, + ); +});