From a5db2e1d6df77107d81e9ac1afaeb0cc76875cdc Mon Sep 17 00:00:00 2001 From: "Adrian T." <68144167+torranx@users.noreply.github.com> Date: Fri, 4 Oct 2024 22:42:05 +0800 Subject: [PATCH 01/16] [Misc] Update readme to include relevant links (#4573) --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index da10290d51d..866687d54b7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,12 @@ If you have the motivation and experience with Typescript/Javascript (or are wil 2. Run `npm run start:dev` to locally run the project in `localhost:8000` #### Linting -We're using ESLint as our common linter and formatter. It will run automatically during the pre-commit hook but if you would like to manually run it, use the `npm run eslint` script. +We're using ESLint as our common linter and formatter. It will run automatically during the pre-commit hook but if you would like to manually run it, use the `npm run eslint` script. To view the complete rules, check out the [eslint.config.js](./eslint.config.js) file. + +### 📚 Documentation +You can find the auto-generated documentation [here](https://pagefaultgames.github.io/pokerogue/main/index.html). +For information on enemy AI, check out the [enemy-ai.md](./docs/enemy-ai.md) file. +For detailed guidelines on documenting your code, refer to the [comments.md](./docs/comments.md) file. ### ❔ FAQ From 2bc5f501545d83690e25fe524d2e01698ec216ac Mon Sep 17 00:00:00 2001 From: PigeonBar <56974298+PigeonBar@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:42:20 -0400 Subject: [PATCH 02/16] [Test] Fix some test flakiness involving `doKillOpponents()` (#4571) * [Test] Fix some test flakiness involving game.doKillOpponents() * PR Feedback * Fix linting --------- Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/test/daily_mode.test.ts | 14 ++++++-------- src/test/reload.test.ts | 12 ++++-------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/test/daily_mode.test.ts b/src/test/daily_mode.test.ts index f832d17cc6c..100cf07f9c0 100644 --- a/src/test/daily_mode.test.ts +++ b/src/test/daily_mode.test.ts @@ -5,6 +5,7 @@ import { Moves } from "#app/enums/moves"; import { Biome } from "#app/enums/biome"; import { Mode } from "#app/ui/ui"; import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; +import { Species } from "#enums/species"; //const TIMEOUT = 20 * 1000; @@ -53,12 +54,11 @@ describe("Shop modifications", async () => { game.override .startingWave(9) - .startingBiome(Biome.ICE_CAVE) // Will lead to Snowy Forest with randomly generated weather + .startingBiome(Biome.ICE_CAVE) .battleType("single") .startingLevel(100) // Avoid levelling up - .enemyLevel(1000) // Avoid opponent dying before game.doKillOpponents() .disableTrainerWaves() - .moveset([ Moves.KOWTOW_CLEAVE ]) + .moveset([ Moves.SPLASH ]) .enemyMoveset(Moves.SPLASH); game.modifiers .addCheck("EVIOLITE") @@ -71,9 +71,8 @@ describe("Shop modifications", async () => { }); it("should not have Eviolite and Mini Black Hole available in Classic if not unlocked", async () => { - await game.classicMode.startBattle(); - game.move.select(Moves.KOWTOW_CLEAVE); - await game.phaseInterceptor.to("DamagePhase"); + await game.classicMode.startBattle([ Species.BULBASAUR ]); + game.move.select(Moves.SPLASH); await game.doKillOpponents(); await game.phaseInterceptor.to("BattleEndPhase"); game.onNextPrompt("SelectModifierPhase", Mode.MODIFIER_SELECT, () => { @@ -86,8 +85,7 @@ describe("Shop modifications", async () => { it("should have Eviolite and Mini Black Hole available in Daily", async () => { await game.dailyMode.startBattle(); - game.move.select(Moves.KOWTOW_CLEAVE); - await game.phaseInterceptor.to("DamagePhase"); + game.move.select(Moves.SPLASH); await game.doKillOpponents(); await game.phaseInterceptor.to("BattleEndPhase"); game.onNextPrompt("SelectModifierPhase", Mode.MODIFIER_SELECT, () => { diff --git a/src/test/reload.test.ts b/src/test/reload.test.ts index 7849f8f0080..b15e9691ed6 100644 --- a/src/test/reload.test.ts +++ b/src/test/reload.test.ts @@ -44,15 +44,13 @@ describe("Reload", () => { .startingWave(10) .battleType("single") .startingLevel(100) // Avoid levelling up - .enemyLevel(1000) // Avoid opponent dying before game.doKillOpponents() .disableTrainerWaves() - .moveset([ Moves.KOWTOW_CLEAVE ]) + .moveset([ Moves.SPLASH ]) .enemyMoveset(Moves.SPLASH); await game.dailyMode.startBattle(); // Transition from Wave 10 to Wave 11 in order to trigger biome switch - game.move.select(Moves.KOWTOW_CLEAVE); - await game.phaseInterceptor.to("DamagePhase"); + game.move.select(Moves.SPLASH); await game.doKillOpponents(); game.onNextPrompt("SelectBiomePhase", Mode.OPTION_SELECT, () => { (game.scene.time as MockClock).overrideDelay = null; @@ -79,15 +77,13 @@ describe("Reload", () => { .startingBiome(Biome.ICE_CAVE) // Will lead to Snowy Forest with randomly generated weather .battleType("single") .startingLevel(100) // Avoid levelling up - .enemyLevel(1000) // Avoid opponent dying before game.doKillOpponents() .disableTrainerWaves() - .moveset([ Moves.KOWTOW_CLEAVE ]) + .moveset([ Moves.SPLASH ]) .enemyMoveset(Moves.SPLASH); await game.classicMode.startBattle(); // Apparently daily mode would override the biome // Transition from Wave 10 to Wave 11 in order to trigger biome switch - game.move.select(Moves.KOWTOW_CLEAVE); - await game.phaseInterceptor.to("DamagePhase"); + game.move.select(Moves.SPLASH); await game.doKillOpponents(); await game.toNextWave(); expect(game.phaseInterceptor.log).toContain("NewBiomeEncounterPhase"); From 22442d3aa066f3ebb6700193c7b9d930cb08712c Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Fri, 4 Oct 2024 07:50:03 -0700 Subject: [PATCH 03/16] [Refactor] Refactor move phase and add documentation (#3974) * Refactor `MovePhase` to improve readability/maintainability Add tsdocs/comments all over Mark all functions/fields with public/etc Fix multi-hit moves called from Metronome/etc, fixes #3914 Remove unused function `BattleScene.pushMovePhase` Don't use failure text as a condition for move success A move defining potential failure text doesn't mean it failed Replace relative imports with absolute imports in `battle-scene.ts` Change some fields from optional to default `false` * Fix Whirlwind test * Fix linting --- src/battle-scene.ts | 149 ++++----- src/field/arena.ts | 12 +- src/field/pokemon.ts | 10 +- src/phases/move-effect-phase.ts | 2 +- src/phases/move-phase.ts | 553 +++++++++++++++++++------------ src/test/moves/whirlwind.test.ts | 10 +- 6 files changed, 431 insertions(+), 305 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 7f17a666280..cc6934f20d1 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -1,57 +1,57 @@ import Phaser from "phaser"; -import UI from "./ui/ui"; -import Pokemon, { EnemyPokemon, PlayerPokemon } from "./field/pokemon"; -import PokemonSpecies, { allSpecies, getPokemonSpecies, PokemonSpeciesFilter } from "./data/pokemon-species"; +import UI from "#app/ui/ui"; +import Pokemon, { EnemyPokemon, PlayerPokemon } from "#app/field/pokemon"; +import PokemonSpecies, { allSpecies, getPokemonSpecies, PokemonSpeciesFilter } from "#app/data/pokemon-species"; import { Constructor, isNullOrUndefined, randSeedInt } from "#app/utils"; -import * as Utils from "./utils"; +import * as Utils from "#app/utils"; import { ConsumableModifier, ConsumablePokemonModifier, DoubleBattleChanceBoosterModifier, ExpBalanceModifier, ExpShareModifier, FusePokemonModifier, HealingBoosterModifier, Modifier, ModifierBar, ModifierPredicate, MultipleParticipantExpBonusModifier, overrideHeldItems, overrideModifiers, PersistentModifier, PokemonExpBoosterModifier, PokemonFormChangeItemModifier, PokemonHeldItemModifier, PokemonHpRestoreModifier, PokemonIncrementingStatModifier, TerastallizeModifier, TurnHeldItemTransferModifier } from "./modifier/modifier"; -import { PokeballType } from "./data/pokeball"; -import { initCommonAnims, initMoveAnim, loadCommonAnimAssets, loadMoveAnimAssets, populateAnims } from "./data/battle-anims"; -import { Phase } from "./phase"; -import { initGameSpeed } from "./system/game-speed"; -import { Arena, ArenaBase } from "./field/arena"; -import { GameData } from "./system/game-data"; -import { addTextObject, getTextColor, TextStyle } from "./ui/text"; -import { allMoves } from "./data/move"; -import { getDefaultModifierTypeForTier, getEnemyModifierTypesForWave, getLuckString, getLuckTextTint, getModifierPoolForType, getModifierType, getPartyLuckValue, ModifierPoolType, modifierTypes, PokemonHeldItemModifierType } from "./modifier/modifier-type"; -import AbilityBar from "./ui/ability-bar"; -import { allAbilities, applyAbAttrs, applyPostBattleInitAbAttrs, BlockItemTheftAbAttr, ChangeMovePriorityAbAttr, DoubleBattleChanceAbAttr, PostBattleInitAbAttr } from "./data/ability"; -import Battle, { BattleType, FixedBattleConfig } from "./battle"; -import { GameMode, GameModes, getGameMode } from "./game-mode"; -import FieldSpritePipeline from "./pipelines/field-sprite"; -import SpritePipeline from "./pipelines/sprite"; -import PartyExpBar from "./ui/party-exp-bar"; -import { trainerConfigs, TrainerSlot } from "./data/trainer-config"; -import Trainer, { TrainerVariant } from "./field/trainer"; -import TrainerData from "./system/trainer-data"; +import { PokeballType } from "#app/data/pokeball"; +import { initCommonAnims, initMoveAnim, loadCommonAnimAssets, loadMoveAnimAssets, populateAnims } from "#app/data/battle-anims"; +import { Phase } from "#app/phase"; +import { initGameSpeed } from "#app/system/game-speed"; +import { Arena, ArenaBase } from "#app/field/arena"; +import { GameData } from "#app/system/game-data"; +import { addTextObject, getTextColor, TextStyle } from "#app/ui/text"; +import { allMoves } from "#app/data/move"; +import { getDefaultModifierTypeForTier, getEnemyModifierTypesForWave, getLuckString, getLuckTextTint, getModifierPoolForType, getModifierType, getPartyLuckValue, ModifierPoolType, modifierTypes, PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; +import AbilityBar from "#app/ui/ability-bar"; +import { allAbilities, applyAbAttrs, applyPostBattleInitAbAttrs, BlockItemTheftAbAttr, DoubleBattleChanceAbAttr, PostBattleInitAbAttr } from "#app/data/ability"; +import Battle, { BattleType, FixedBattleConfig } from "#app/battle"; +import { GameMode, GameModes, getGameMode } from "#app/game-mode"; +import FieldSpritePipeline from "#app/pipelines/field-sprite"; +import SpritePipeline from "#app/pipelines/sprite"; +import PartyExpBar from "#app/ui/party-exp-bar"; +import { trainerConfigs, TrainerSlot } from "#app/data/trainer-config"; +import Trainer, { TrainerVariant } from "#app/field/trainer"; +import TrainerData from "#app/system/trainer-data"; import SoundFade from "phaser3-rex-plugins/plugins/soundfade"; -import { pokemonPrevolutions } from "./data/balance/pokemon-evolutions"; -import PokeballTray from "./ui/pokeball-tray"; -import InvertPostFX from "./pipelines/invert"; -import { Achv, achvs, ModifierAchv, MoneyAchv } from "./system/achv"; -import { Voucher, vouchers } from "./system/voucher"; -import { Gender } from "./data/gender"; +import { pokemonPrevolutions } from "#app/data/balance/pokemon-evolutions"; +import PokeballTray from "#app/ui/pokeball-tray"; +import InvertPostFX from "#app/pipelines/invert"; +import { Achv, achvs, ModifierAchv, MoneyAchv } from "#app/system/achv"; +import { Voucher, vouchers } from "#app/system/voucher"; +import { Gender } from "#app/data/gender"; import UIPlugin from "phaser3-rex-plugins/templates/ui/ui-plugin"; -import { addUiThemeOverrides } from "./ui/ui-theme"; -import PokemonData from "./system/pokemon-data"; -import { Nature } from "./data/nature"; -import { FormChangeItem, pokemonFormChanges, SpeciesFormChange, SpeciesFormChangeManualTrigger, SpeciesFormChangeTimeOfDayTrigger, SpeciesFormChangeTrigger } from "./data/pokemon-forms"; -import { FormChangePhase } from "./phases/form-change-phase"; -import { getTypeRgb } from "./data/type"; -import PokemonSpriteSparkleHandler from "./field/pokemon-sprite-sparkle-handler"; -import CharSprite from "./ui/char-sprite"; -import DamageNumberHandler from "./field/damage-number-handler"; -import PokemonInfoContainer from "./ui/pokemon-info-container"; -import { biomeDepths, getBiomeName } from "./data/balance/biomes"; -import { SceneBase } from "./scene-base"; -import CandyBar from "./ui/candy-bar"; -import { Variant, variantData } from "./data/variant"; +import { addUiThemeOverrides } from "#app/ui/ui-theme"; +import PokemonData from "#app/system/pokemon-data"; +import { Nature } from "#app/data/nature"; +import { FormChangeItem, pokemonFormChanges, SpeciesFormChange, SpeciesFormChangeManualTrigger, SpeciesFormChangeTimeOfDayTrigger, SpeciesFormChangeTrigger } from "#app/data/pokemon-forms"; +import { FormChangePhase } from "#app/phases/form-change-phase"; +import { getTypeRgb } from "#app/data/type"; +import PokemonSpriteSparkleHandler from "#app/field/pokemon-sprite-sparkle-handler"; +import CharSprite from "#app/ui/char-sprite"; +import DamageNumberHandler from "#app/field/damage-number-handler"; +import PokemonInfoContainer from "#app/ui/pokemon-info-container"; +import { biomeDepths, getBiomeName } from "#app/data/balance/biomes"; +import { SceneBase } from "#app/scene-base"; +import CandyBar from "#app/ui/candy-bar"; +import { Variant, variantData } from "#app/data/variant"; import { Localizable } from "#app/interfaces/locales"; import Overrides from "#app/overrides"; -import { InputsController } from "./inputs-controller"; -import { UiInputs } from "./ui-inputs"; -import { NewArenaEvent } from "./events/battle-scene"; -import { ArenaFlyout } from "./ui/arena-flyout"; +import { InputsController } from "#app/inputs-controller"; +import { UiInputs } from "#app/ui-inputs"; +import { NewArenaEvent } from "#app/events/battle-scene"; +import { ArenaFlyout } from "#app/ui/arena-flyout"; import { EaseType } from "#enums/ease-type"; import { BattleSpec } from "#enums/battle-spec"; import { BattleStyle } from "#enums/battle-style"; @@ -66,27 +66,27 @@ import { TimedEventManager } from "#app/timed-event-manager"; import { PokemonAnimType } from "#enums/pokemon-anim-type"; import i18next from "i18next"; import { TrainerType } from "#enums/trainer-type"; -import { battleSpecDialogue } from "./data/dialogue"; -import { LoadingScene } from "./loading-scene"; -import { LevelCapPhase } from "./phases/level-cap-phase"; -import { LoginPhase } from "./phases/login-phase"; -import { MessagePhase } from "./phases/message-phase"; -import { MovePhase } from "./phases/move-phase"; -import { NewBiomeEncounterPhase } from "./phases/new-biome-encounter-phase"; -import { NextEncounterPhase } from "./phases/next-encounter-phase"; -import { PokemonAnimPhase } from "./phases/pokemon-anim-phase"; -import { QuietFormChangePhase } from "./phases/quiet-form-change-phase"; -import { ReturnPhase } from "./phases/return-phase"; -import { SelectBiomePhase } from "./phases/select-biome-phase"; -import { ShowTrainerPhase } from "./phases/show-trainer-phase"; -import { SummonPhase } from "./phases/summon-phase"; -import { SwitchPhase } from "./phases/switch-phase"; -import { TitlePhase } from "./phases/title-phase"; -import { ToggleDoublePositionPhase } from "./phases/toggle-double-position-phase"; -import { TurnInitPhase } from "./phases/turn-init-phase"; -import { ShopCursorTarget } from "./enums/shop-cursor-target"; -import MysteryEncounter from "./data/mystery-encounters/mystery-encounter"; -import { allMysteryEncounters, ANTI_VARIANCE_WEIGHT_MODIFIER, AVERAGE_ENCOUNTERS_PER_RUN_TARGET, BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT, MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT, mysteryEncountersByBiome, WEIGHT_INCREMENT_ON_SPAWN_MISS } from "./data/mystery-encounters/mystery-encounters"; +import { battleSpecDialogue } from "#app/data/dialogue"; +import { LoadingScene } from "#app/loading-scene"; +import { LevelCapPhase } from "#app/phases/level-cap-phase"; +import { LoginPhase } from "#app/phases/login-phase"; +import { MessagePhase } from "#app/phases/message-phase"; +import { MovePhase } from "#app/phases/move-phase"; +import { NewBiomeEncounterPhase } from "#app/phases/new-biome-encounter-phase"; +import { NextEncounterPhase } from "#app/phases/next-encounter-phase"; +import { PokemonAnimPhase } from "#app/phases/pokemon-anim-phase"; +import { QuietFormChangePhase } from "#app/phases/quiet-form-change-phase"; +import { ReturnPhase } from "#app/phases/return-phase"; +import { SelectBiomePhase } from "#app/phases/select-biome-phase"; +import { ShowTrainerPhase } from "#app/phases/show-trainer-phase"; +import { SummonPhase } from "#app/phases/summon-phase"; +import { SwitchPhase } from "#app/phases/switch-phase"; +import { TitlePhase } from "#app/phases/title-phase"; +import { ToggleDoublePositionPhase } from "#app/phases/toggle-double-position-phase"; +import { TurnInitPhase } from "#app/phases/turn-init-phase"; +import { ShopCursorTarget } from "#app/enums/shop-cursor-target"; +import MysteryEncounter from "#app/data/mystery-encounters/mystery-encounter"; +import { allMysteryEncounters, ANTI_VARIANCE_WEIGHT_MODIFIER, AVERAGE_ENCOUNTERS_PER_RUN_TARGET, BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT, MYSTERY_ENCOUNTER_SPAWN_MAX_WEIGHT, mysteryEncountersByBiome, WEIGHT_INCREMENT_ON_SPAWN_MISS } from "#app/data/mystery-encounters/mystery-encounters"; import { MysteryEncounterSaveData } from "#app/data/mystery-encounters/mystery-encounter-save-data"; import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; @@ -94,7 +94,7 @@ import HeldModifierConfig from "#app/interfaces/held-modifier-config"; import { ExpPhase } from "#app/phases/exp-phase"; import { ShowPartyExpBarPhase } from "#app/phases/show-party-exp-bar-phase"; import { MysteryEncounterMode } from "#enums/mystery-encounter-mode"; -import { ExpGainsSpeed } from "./enums/exp-gains-speed"; +import { ExpGainsSpeed } from "#enums/exp-gains-speed"; export const bypassLogin = import.meta.env.VITE_BYPASS_LOGIN === "1"; @@ -2359,17 +2359,6 @@ export default class BattleScene extends SceneBase { return false; } - pushMovePhase(movePhase: MovePhase, priorityOverride?: integer): void { - const movePriority = new Utils.IntegerHolder(priorityOverride !== undefined ? priorityOverride : movePhase.move.getMove().priority); - applyAbAttrs(ChangeMovePriorityAbAttr, movePhase.pokemon, null, false, movePhase.move.getMove(), movePriority); - const lowerPriorityPhase = this.phaseQueue.find(p => p instanceof MovePhase && p.move.getMove().priority < movePriority.value); - if (lowerPriorityPhase) { - this.phaseQueue.splice(this.phaseQueue.indexOf(lowerPriorityPhase), 0, movePhase); - } else { - this.pushPhase(movePhase); - } - } - /** * Tries to add the input phase to index before target phase in the phaseQueue, else simply calls unshiftPhase() * @param phase {@linkcode Phase} the phase to be added diff --git a/src/field/arena.ts b/src/field/arena.ts index 9d5f1eb0a4e..1e164903e9d 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -392,16 +392,16 @@ export class Arena { return true; } - isMoveWeatherCancelled(user: Pokemon, move: Move) { - return this.weather && !this.weather.isEffectSuppressed(this.scene) && this.weather.isMoveWeatherCancelled(user, move); + public isMoveWeatherCancelled(user: Pokemon, move: Move): boolean { + return !!this.weather && !this.weather.isEffectSuppressed(this.scene) && this.weather.isMoveWeatherCancelled(user, move); } - isMoveTerrainCancelled(user: Pokemon, targets: BattlerIndex[], move: Move) { - return this.terrain && this.terrain.isMoveTerrainCancelled(user, targets, move); + public isMoveTerrainCancelled(user: Pokemon, targets: BattlerIndex[], move: Move): boolean { + return !!this.terrain && this.terrain.isMoveTerrainCancelled(user, targets, move); } - getTerrainType() : TerrainType { - return this.terrain?.terrainType || TerrainType.NONE; + public getTerrainType(): TerrainType { + return this.terrain?.terrainType ?? TerrainType.NONE; } getAttackTypeMultiplier(attackType: Type, grounded: boolean): number { diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index fed91d05fd5..05567491a1a 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -5027,8 +5027,12 @@ export class PokemonBattleSummonData { export class PokemonTurnData { public flinched: boolean = false; public acted: boolean = false; - public hitCount: number; - public hitsLeft: number; + public hitCount: number = 0; + /** + * - `-1` = Calculate how many hits are left + * - `0` = Move is finished + */ + public hitsLeft: number = -1; public damageDealt: number = 0; public currDamageDealt: number = 0; public damageTaken: number = 0; @@ -5114,7 +5118,7 @@ export class PokemonMove { * @param {boolean} ignoreRestrictionTags If `true`, skips the check for move restriction tags (see {@link MoveRestrictionBattlerTag}) * @returns `true` if the move can be selected and used by the Pokemon, otherwise `false`. */ - isUsable(pokemon: Pokemon, ignorePp?: boolean, ignoreRestrictionTags?: boolean): boolean { + isUsable(pokemon: Pokemon, ignorePp: boolean = false, ignoreRestrictionTags: boolean = false): boolean { if (this.moveId && !ignoreRestrictionTags && pokemon.isMoveRestricted(this.moveId)) { return false; } diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 93466babb77..b2d429a4313 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -70,7 +70,7 @@ export class MoveEffectPhase extends PokemonPhase { * resolve the move's total hit count. This block combines the * effects of the move itself, Parental Bond, and Multi-Lens to do so. */ - if (user.turnData.hitsLeft === undefined) { + if (user.turnData.hitsLeft === -1) { const hitCount = new Utils.IntegerHolder(1); // Assume single target for multi hit applyMoveAttrs(MultiHitAttr, user, this.getTarget() ?? null, move, hitCount); diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 154fbbe410d..807f194bad5 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -1,5 +1,5 @@ -import BattleScene from "#app/battle-scene"; import { BattlerIndex } from "#app/battle"; +import BattleScene from "#app/battle-scene"; import { applyAbAttrs, applyPostMoveUsedAbAttrs, applyPreAttackAbAttrs, BlockRedirectAbAttr, IncreasePpAbAttr, PokemonTypeChangeAbAttr, PostMoveUsedAbAttr, RedirectMoveAbAttr } from "#app/data/ability"; import { CommonAnim } from "#app/data/battle-anims"; import { BattlerTagLapseType, CenterOfAttentionTag } from "#app/data/battler-tags"; @@ -15,236 +15,149 @@ import { StatusEffect } from "#app/enums/status-effect"; import { MoveUsedEvent } from "#app/events/battle-scene"; import Pokemon, { MoveResult, PokemonMove, TurnMove } from "#app/field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; +import { BattlePhase } from "#app/phases/battle-phase"; +import { CommonAnimPhase } from "#app/phases/common-anim-phase"; +import { MoveEffectPhase } from "#app/phases/move-effect-phase"; +import { MoveEndPhase } from "#app/phases/move-end-phase"; +import { ShowAbilityPhase } from "#app/phases/show-ability-phase"; import * as Utils from "#app/utils"; import i18next from "i18next"; -import { BattlePhase } from "./battle-phase"; -import { CommonAnimPhase } from "./common-anim-phase"; -import { MoveEffectPhase } from "./move-effect-phase"; -import { MoveEndPhase } from "./move-end-phase"; -import { ShowAbilityPhase } from "./show-ability-phase"; export class MovePhase extends BattlePhase { - public pokemon: Pokemon; - public move: PokemonMove; - public targets: BattlerIndex[]; + protected _pokemon: Pokemon; + protected _move: PokemonMove; + protected _targets: BattlerIndex[]; protected followUp: boolean; protected ignorePp: boolean; - protected failed: boolean; - protected cancelled: boolean; + protected failed: boolean = false; + protected cancelled: boolean = false; - constructor(scene: BattleScene, pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, followUp?: boolean, ignorePp?: boolean) { + public get pokemon(): Pokemon { + return this._pokemon; + } + + protected set pokemon(pokemon: Pokemon) { + this._pokemon = pokemon; + } + + public get move(): PokemonMove { + return this._move; + } + + protected set move(move: PokemonMove) { + this._move = move; + } + + public get targets(): BattlerIndex[] { + return this._targets; + } + + protected set targets(targets: BattlerIndex[]) { + this._targets = targets; + } + + /** + * @param followUp Indicates that the move being uses is a "follow-up" - for example, a move being used by Metronome or Dancer. + * Follow-ups bypass a few failure conditions, including flinches, sleep/paralysis/freeze and volatile status checks, etc. + */ + constructor(scene: BattleScene, pokemon: Pokemon, targets: BattlerIndex[], move: PokemonMove, followUp: boolean = false, ignorePp: boolean = false) { super(scene); this.pokemon = pokemon; this.targets = targets; this.move = move; - this.followUp = followUp ?? false; - this.ignorePp = ignorePp ?? false; - this.failed = false; - this.cancelled = false; + this.followUp = followUp; + this.ignorePp = ignorePp; } - canMove(ignoreDisableTags?: boolean): boolean { + /** + * Checks if the pokemon is active, if the move is usable, and that the move is targetting something. + * @param ignoreDisableTags `true` to not check if the move is disabled + * @returns `true` if all the checks pass + */ + public canMove(ignoreDisableTags: boolean = false): boolean { return this.pokemon.isActive(true) && this.move.isUsable(this.pokemon, this.ignorePp, ignoreDisableTags) && !!this.targets.length; } /**Signifies the current move should fail but still use PP */ - fail(): void { + public fail(): void { this.failed = true; } /**Signifies the current move should cancel and retain PP */ - cancel(): void { + public cancel(): void { this.cancelled = true; } - start() { + public start() { super.start(); console.log(Moves[this.move.moveId]); + // Check if move is unusable (e.g. because it's out of PP due to a mid-turn Spite). if (!this.canMove(true)) { - if (this.pokemon.isActive(true) && this.move.ppUsed >= this.move.getMovePp()) { // if the move PP was reduced from Spite or otherwise, the move fails + if (this.pokemon.isActive(true) && this.move.ppUsed >= this.move.getMovePp()) { this.fail(); this.showMoveText(); this.showFailedText(); } + return this.end(); } + this.pokemon.turnData.acted = true; + + // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats) + if (this.followUp) { + this.pokemon.turnData.hitsLeft = -1; + this.pokemon.turnData.hitCount = 0; + } + + // Check move to see if arena.ignoreAbilities should be true. if (!this.followUp) { if (this.move.getMove().checkFlag(MoveFlags.IGNORE_ABILITIES, this.pokemon, null)) { this.scene.arena.setIgnoreAbilities(true, this.pokemon.getBattlerIndex()); } + } + + this.resolveRedirectTarget(); + + this.resolveCounterAttackTarget(); + + this.resolvePreMoveStatusEffects(); + + this.lapsePreMoveAndMoveTags(); + + this.resolveFinalPreMoveCancellationChecks(); + + if (this.cancelled || this.failed) { + this.handlePreMoveFailures(); } else { - this.pokemon.turnData.hitsLeft = 0; // TODO: is `0` correct? - this.pokemon.turnData.hitCount = 0; // TODO: is `0` correct? + this.useMove(); } - // Move redirection abilities (ie. Storm Drain) only support single target moves - const moveTarget = this.targets.length === 1 - ? new Utils.IntegerHolder(this.targets[0]) - : null; - if (moveTarget) { - const oldTarget = moveTarget.value; - this.scene.getField(true).filter(p => p !== this.pokemon).forEach(p => applyAbAttrs(RedirectMoveAbAttr, p, null, false, this.move.moveId, moveTarget)); - this.pokemon.getOpponents().forEach(p => { - const redirectTag = p.getTag(CenterOfAttentionTag) as CenterOfAttentionTag; - if (redirectTag && (!redirectTag.powder || (!this.pokemon.isOfType(Type.GRASS) && !this.pokemon.hasAbility(Abilities.OVERCOAT)))) { - moveTarget.value = p.getBattlerIndex(); - } - }); - //Check if this move is immune to being redirected, and restore its target to the intended target if it is. - if ((this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr) || this.move.getMove().hasAttr(BypassRedirectAttr))) { - //If an ability prevented this move from being redirected, display its ability pop up. - if ((this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr) && !this.move.getMove().hasAttr(BypassRedirectAttr)) && oldTarget !== moveTarget.value) { - this.scene.unshiftPhase(new ShowAbilityPhase(this.scene, this.pokemon.getBattlerIndex(), this.pokemon.getPassiveAbility().hasAttr(BlockRedirectAbAttr))); - } - moveTarget.value = oldTarget; - } - this.targets[0] = moveTarget.value; + this.end(); + } + + /** Check for cancellation edge cases - no targets remaining, or {@linkcode Moves.NONE} is in the queue */ + protected resolveFinalPreMoveCancellationChecks() { + const targets = this.getActiveTargetPokemon(); + const moveQueue = this.pokemon.getMoveQueue(); + + if (targets.length === 0 || (moveQueue.length && moveQueue[0].move === Moves.NONE)) { + this.showFailedText(); + this.cancelled = true; } + } - // Check for counterattack moves to switch target - if (this.targets.length === 1 && this.targets[0] === BattlerIndex.ATTACKER) { - if (this.pokemon.turnData.attacksReceived.length) { - const attack = this.pokemon.turnData.attacksReceived[0]; - this.targets[0] = attack.sourceBattlerIndex; - - // account for metal burst and comeuppance hitting remaining targets in double battles - // counterattack will redirect to remaining ally if original attacker faints - if (this.scene.currentBattle.double && this.move.getMove().hasFlag(MoveFlags.REDIRECT_COUNTER)) { - if (this.scene.getField()[this.targets[0]].hp === 0) { - const opposingField = this.pokemon.isPlayer() ? this.scene.getEnemyField() : this.scene.getPlayerField(); - //@ts-ignore - this.targets[0] = opposingField.find(p => p.hp > 0)?.getBattlerIndex(); //TODO: fix ts-ignore - } - } - } - if (this.targets[0] === BattlerIndex.ATTACKER) { - this.fail(); // Marks the move as failed for later in doMove - this.showMoveText(); - this.showFailedText(); - } - } - - const targets = this.scene.getField(true).filter(p => { - if (this.targets.indexOf(p.getBattlerIndex()) > -1) { - return true; - } - return false; - }); - - const doMove = () => { - this.pokemon.turnData.acted = true; // Record that the move was attempted, even if it fails - - this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE); - - let ppUsed = 1; - // Filter all opponents to include only those this move is targeting - const targetedOpponents = this.pokemon.getOpponents().filter(o => this.targets.includes(o.getBattlerIndex())); - for (const opponent of targetedOpponents) { - if (this.move.ppUsed + ppUsed >= this.move.getMovePp()) { // If we're already at max PP usage, stop checking - break; - } - if (opponent.hasAbilityWithAttr(IncreasePpAbAttr)) { // Accounting for abilities like Pressure - ppUsed++; - } - } - - if (!this.followUp && this.canMove() && !this.cancelled) { - this.pokemon.lapseTags(BattlerTagLapseType.MOVE); - } - - const moveQueue = this.pokemon.getMoveQueue(); - if (this.cancelled || this.failed) { - if (this.failed) { - this.move.usePp(ppUsed); // Only use PP if the move failed - this.scene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), this.move.ppUsed)); - } - - // Record a failed move so Abilities like Truant don't trigger next turn and soft-lock - this.pokemon.pushMoveHistory({ move: Moves.NONE, result: MoveResult.FAIL }); - - this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); // Remove any tags from moves like Fly/Dive/etc. - this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE); - moveQueue.shift(); // Remove the second turn of charge moves - return this.end(); - } - - this.scene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); - - if (this.move.moveId) { - this.showMoveText(); - } - - // This should only happen when there are no valid targets left on the field - if ((moveQueue.length && moveQueue[0].move === Moves.NONE) || !targets.length) { - this.showFailedText(); - this.cancel(); - - // Record a failed move so Abilities like Truant don't trigger next turn and soft-lock - this.pokemon.pushMoveHistory({ move: Moves.NONE, result: MoveResult.FAIL }); - - this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); // Remove any tags from moves like Fly/Dive/etc. - this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE); - - moveQueue.shift(); - return this.end(); - } - - if ((!moveQueue.length || !moveQueue.shift()?.ignorePP) && !this.ignorePp) { // using .shift here clears out two turn moves once they've been used - this.move.usePp(ppUsed); - this.scene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), this.move.ppUsed)); - } - - if (!allMoves[this.move.moveId].hasAttr(CopyMoveAttr)) { - this.scene.currentBattle.lastMove = this.move.moveId; - } - - // Assume conditions affecting targets only apply to moves with a single target - let success = this.move.getMove().applyConditions(this.pokemon, targets[0], this.move.getMove()); - const cancelled = new Utils.BooleanHolder(false); - let failedText = this.move.getMove().getFailedText(this.pokemon, targets[0], this.move.getMove(), cancelled); - if (success && this.scene.arena.isMoveWeatherCancelled(this.pokemon, this.move.getMove())) { - success = false; - } else if (success && this.scene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, this.move.getMove())) { - success = false; - if (failedText === null) { - failedText = getTerrainBlockMessage(targets[0], this.scene.arena.terrain?.terrainType!); // TODO: is this bang correct? - } - } - - /** - * Trigger pokemon type change before playing the move animation - * Will still change the user's type when using Roar, Whirlwind, Trick-or-Treat, and Forest's Curse, - * regardless of whether the move successfully executes or not. - */ - if (success || [ Moves.ROAR, Moves.WHIRLWIND, Moves.TRICK_OR_TREAT, Moves.FORESTS_CURSE ].includes(this.move.moveId)) { - applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove()); - } - - if (success) { - this.scene.unshiftPhase(this.getEffectPhase()); - } else { - this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, result: MoveResult.FAIL, virtual: this.move.virtual }); - if (!cancelled.value) { - this.showFailedText(failedText); - } - } - // Checks if Dancer ability is triggered - if (this.move.getMove().hasFlag(MoveFlags.DANCE_MOVE) && !this.followUp) { - // Pokemon with Dancer can be on either side of the battle so we check in both cases - this.scene.getPlayerField().forEach(pokemon => { - applyPostMoveUsedAbAttrs(PostMoveUsedAbAttr, pokemon, this.move, this.pokemon, this.targets); - }); - this.scene.getEnemyField().forEach(pokemon => { - applyPostMoveUsedAbAttrs(PostMoveUsedAbAttr, pokemon, this.move, this.pokemon, this.targets); - }); - } - this.end(); - }; + public getActiveTargetPokemon() { + return this.scene.getField(true).filter(p => this.targets.includes(p.getBattlerIndex())); + } + /** + * Handles {@link StatusEffect.SLEEP Sleep}/{@link StatusEffect.PARALYSIS Paralysis}/{@link StatusEffect.FREEZE Freeze} rolls and side effects. + */ + protected resolvePreMoveStatusEffects() { if (!this.followUp && this.pokemon.status && !this.pokemon.status.isPostTurn()) { this.pokemon.status.incrementTurn(); let activated = false; @@ -273,25 +186,257 @@ export class MovePhase extends BattlePhase { if (activated) { this.scene.queueMessage(getStatusEffectActivationText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon))); this.scene.unshiftPhase(new CommonAnimPhase(this.scene, this.pokemon.getBattlerIndex(), undefined, CommonAnim.POISON + (this.pokemon.status.effect - 1))); - doMove(); - } else { - if (healed) { - this.scene.queueMessage(getStatusEffectHealText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon))); - this.pokemon.resetStatus(); - this.pokemon.updateInfo(); - } - doMove(); + } else if (healed) { + this.scene.queueMessage(getStatusEffectHealText(this.pokemon.status.effect, getPokemonNameWithAffix(this.pokemon))); + this.pokemon.resetStatus(); + this.pokemon.updateInfo(); } - } else { - doMove(); } } - getEffectPhase(): MoveEffectPhase { - return new MoveEffectPhase(this.scene, this.pokemon.getBattlerIndex(), this.targets, this.move); + /** + * Lapse {@linkcode BattlerTagLapseType.PRE_MOVE PRE_MOVE} tags that trigger before a move is used, regardless of whether or not it failed. + * Also lapse {@linkcode BattlerTagLapseType.MOVE MOVE} tags if the move should be successful. + */ + protected lapsePreMoveAndMoveTags() { + this.pokemon.lapseTags(BattlerTagLapseType.PRE_MOVE); + + // TODO: does this intentionally happen before the no targets/Moves.NONE on queue cancellation case is checked? + if (!this.followUp && this.canMove() && !this.cancelled) { + this.pokemon.lapseTags(BattlerTagLapseType.MOVE); + } } - showMoveText(): void { + protected useMove() { + const targets = this.getActiveTargetPokemon(); + const moveQueue = this.pokemon.getMoveQueue(); + + // form changes happen even before we know that the move wll execute. + this.scene.triggerPokemonFormChange(this.pokemon, SpeciesFormChangePreMoveTrigger); + + this.showMoveText(); + + // TODO: Clean up implementation of two-turn moves. + if (moveQueue.length > 0) { // Using .shift here clears out two turn moves once they've been used + this.ignorePp = moveQueue.shift()?.ignorePP ?? false; + } + + // "commit" to using the move, deducting PP. + if (!this.ignorePp) { + const ppUsed = 1 + this.getPpIncreaseFromPressure(targets); + + this.move.usePp(ppUsed); + this.scene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed)); + } + + // Update the battle's "last move" pointer, unless we're currently mimicking a move. + if (!allMoves[this.move.moveId].hasAttr(CopyMoveAttr)) { + this.scene.currentBattle.lastMove = this.move.moveId; + } + + /** + * Determine if the move is successful (meaning that its damage/effects can be attempted) + * by checking that all of the following are true: + * - Conditional attributes of the move are all met + * - The target's `ForceSwitchOutImmunityAbAttr` is not triggered (see {@linkcode Move.prototype.applyConditions}) + * - Weather does not block the move + * - Terrain does not block the move + * + * TODO: These steps are straightforward, but the implementation below is extremely convoluted. + */ + + const move = this.move.getMove(); + + /** + * Move conditions assume the move has a single target + * TODO: is this sustainable? + */ + const passesConditions = move.applyConditions(this.pokemon, targets[0], move); + const failedDueToWeather: boolean = this.scene.arena.isMoveWeatherCancelled(this.pokemon, move); + const failedDueToTerrain: boolean = this.scene.arena.isMoveTerrainCancelled(this.pokemon, this.targets, move); + + const success = passesConditions && !failedDueToWeather && !failedDueToTerrain; + + /** + * If the move has not failed, trigger ability-based user type changes and then execute it. + * + * Notably, Roar, Whirlwind, Trick-or-Treat, and Forest's Curse will trigger these type changes even + * if the move fails. + */ + if (success) { + applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove()); + this.scene.unshiftPhase(new MoveEffectPhase(this.scene, this.pokemon.getBattlerIndex(), this.targets, this.move)); + + } else { + if ([ Moves.ROAR, Moves.WHIRLWIND, Moves.TRICK_OR_TREAT, Moves.FORESTS_CURSE ].includes(this.move.moveId)) { + applyPreAttackAbAttrs(PokemonTypeChangeAbAttr, this.pokemon, null, this.move.getMove()); + } + + this.pokemon.pushMoveHistory({ move: this.move.moveId, targets: this.targets, result: MoveResult.FAIL, virtual: this.move.virtual }); + + let failedText: string | undefined; + const failureMessage = move.getFailedText(this.pokemon, targets[0], move, new Utils.BooleanHolder(false)); + + if (failureMessage) { + failedText = failureMessage; + } else if (failedDueToTerrain) { + failedText = getTerrainBlockMessage(this.pokemon, this.scene.arena.getTerrainType()); + } + + this.showFailedText(failedText); + } + + // Handle Dancer, which triggers immediately after a move is used (rather than waiting on `this.end()`). + // Note that the `!this.followUp` check here prevents an infinite Dancer loop. + if (this.move.getMove().hasFlag(MoveFlags.DANCE_MOVE) && !this.followUp) { + this.scene.getField(true).forEach(pokemon => { + applyPostMoveUsedAbAttrs(PostMoveUsedAbAttr, pokemon, this.move, this.pokemon, this.targets); + }); + } + } + + /** + * Queues a {@linkcode MoveEndPhase} if the move wasn't a {@linkcode followUp} and {@linkcode canMove()} returns `true`, + * then ends the phase. + */ + public end() { + if (!this.followUp && this.canMove()) { + this.scene.unshiftPhase(new MoveEndPhase(this.scene, this.pokemon.getBattlerIndex())); + } + + super.end(); + } + + /** + * Applies PP increasing abilities (currently only {@link Abilities.PRESSURE Pressure}) if they exist on the target pokemon. + * Note that targets must include only active pokemon. + * + * TODO: This hardcodes the PP increase at 1 per opponent, rather than deferring to the ability. + */ + public getPpIncreaseFromPressure(targets: Pokemon[]) { + const foesWithPressure = this.pokemon.getOpponents().filter(o => targets.includes(o) && o.isActive(true) && o.hasAbilityWithAttr(IncreasePpAbAttr)); + return foesWithPressure.length; + } + + /** + * Modifies `this.targets` in place, based upon: + * - Move redirection abilities, effects, etc. + * - Counterattacks, which pass a special value into the `targets` constructor param (`[`{@linkcode BattlerIndex.ATTACKER}`]`). + */ + protected resolveRedirectTarget() { + if (this.targets.length === 1) { + const currentTarget = this.targets[0]; + const redirectTarget = new Utils.NumberHolder(currentTarget); + + // 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)); + + // 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; + + // 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(); + } + }); + + if (currentTarget !== redirectTarget.value) { + if (this.move.getMove().hasAttr(BypassRedirectAttr)) { + redirectTarget.value = currentTarget; + + } else if (this.pokemon.hasAbilityWithAttr(BlockRedirectAbAttr)) { + redirectTarget.value = currentTarget; + this.scene.unshiftPhase(new ShowAbilityPhase(this.scene, this.pokemon.getBattlerIndex(), this.pokemon.getPassiveAbility().hasAttr(BlockRedirectAbAttr))); + } + + this.targets[0] = redirectTarget.value; + } + } + } + + /** + * Counter-attacking moves pass in `[`{@linkcode BattlerIndex.ATTACKER}`]` into the constructor's `targets` param. + * This function modifies `this.targets` to reflect the actual battler index of the user's last + * attacker. + * + * If there is no last attacker, or they are no longer on the field, a message is displayed and the + * move is marked for failure. + */ + protected resolveCounterAttackTarget() { + if (this.targets.length === 1 && this.targets[0] === BattlerIndex.ATTACKER) { + if (this.pokemon.turnData.attacksReceived.length) { + const attacker = this.pokemon.scene.getPokemonById(this.pokemon.turnData.attacksReceived[0].sourceId); + + if (attacker?.isActive(true)) { + this.targets[0] = attacker.getBattlerIndex(); + } + + // account for metal burst and comeuppance hitting remaining targets in double battles + // counterattack will redirect to remaining ally if original attacker faints + if (this.scene.currentBattle.double && this.move.getMove().hasFlag(MoveFlags.REDIRECT_COUNTER)) { + if (this.scene.getField()[this.targets[0]].hp === 0) { + const opposingField = this.pokemon.isPlayer() ? this.scene.getEnemyField() : this.scene.getPlayerField(); + this.targets[0] = opposingField.find(p => p.hp > 0)?.getBattlerIndex() ?? BattlerIndex.ATTACKER; + } + } + } + + if (this.targets[0] === BattlerIndex.ATTACKER) { + this.fail(); + this.showMoveText(); + this.showFailedText(); + } + } + } + + /** + * Handles the case where the move was cancelled or failed: + * - Uses PP if the move failed (not cancelled) and should use PP (failed moves are not affected by {@link Abilities.PRESSURE Pressure}) + * - Records a cancelled OR failed move in move history, so abilities like {@link Abilities.TRUANT Truant} don't trigger on the + * next turn and soft-lock. + * - Lapses `MOVE_EFFECT` tags: + * - Semi-invulnerable battler tags (Fly/Dive/etc.) are intended to lapse on move effects, but also need + * to lapse on move failure/cancellation. + * + * TODO: ...this seems weird. + * - Lapses `AFTER_MOVE` tags: + * - This handles the effects of {@link Moves.SUBSTITUTE Substitute} + * - Removes the second turn of charge moves + * + * TODO: handle charge moves more gracefully + */ + protected handlePreMoveFailures() { + if (this.cancelled || this.failed) { + if (this.failed) { + const ppUsed = this.ignorePp ? 0 : 1; + + if (ppUsed) { + this.move.usePp(); + } + + this.scene.eventTarget.dispatchEvent(new MoveUsedEvent(this.pokemon?.id, this.move.getMove(), ppUsed)); + } + + this.pokemon.pushMoveHistory({ move: Moves.NONE, result: MoveResult.FAIL }); + + this.pokemon.lapseTags(BattlerTagLapseType.MOVE_EFFECT); + this.pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE); + + this.pokemon.getMoveQueue().shift(); + } + } + + /** + * Displays the move's usage text to the player, unless it's a charge turn (ie: {@link Moves.SOLAR_BEAM Solar Beam}), + * the pokemon is on a recharge turn (ie: {@link Moves.HYPER_BEAM Hyper Beam}), or a 2-turn move was interrupted (ie: {@link Moves.FLY Fly}). + */ + protected showMoveText(): void { + if (this.move.moveId === Moves.NONE) { + return; + } + if (this.move.getMove().hasAttr(ChargeAttr)) { const lastMove = this.pokemon.getLastXMoves() as TurnMove[]; if (!lastMove.length || lastMove[0].move !== this.move.getMove().id || lastMove[0].result !== MoveResult.OTHER) { @@ -311,18 +456,10 @@ export class MovePhase extends BattlePhase { pokemonNameWithAffix: getPokemonNameWithAffix(this.pokemon), moveName: this.move.getName() }), 500); - applyMoveAttrs(PreMoveMessageAttr, this.pokemon, this.pokemon.getOpponents().find(() => true)!, this.move.getMove()); //TODO: is the bang correct here? + applyMoveAttrs(PreMoveMessageAttr, this.pokemon, this.pokemon.getOpponents()[0], this.move.getMove()); } - showFailedText(failedText: string | null = null): void { - this.scene.queueMessage(failedText || i18next.t("battle:attackFailed")); - } - - end() { - if (!this.followUp && this.canMove()) { - this.scene.unshiftPhase(new MoveEndPhase(this.scene, this.pokemon.getBattlerIndex())); - } - - super.end(); + protected showFailedText(failedText?: string): void { + this.scene.queueMessage(failedText ?? i18next.t("battle:attackFailed")); } } diff --git a/src/test/moves/whirlwind.test.ts b/src/test/moves/whirlwind.test.ts index c8ad29a23d7..cc31b2591a2 100644 --- a/src/test/moves/whirlwind.test.ts +++ b/src/test/moves/whirlwind.test.ts @@ -1,12 +1,11 @@ -import { BattlerIndex } from "#app/battle"; -import { allMoves } from "#app/data/move"; import { BattlerTagType } from "#app/enums/battler-tag-type"; +import { MoveResult } from "#app/field/pokemon"; 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"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; describe("Moves - Whirlwind", () => { let phaserGame: Phaser.Game; @@ -40,14 +39,11 @@ describe("Moves - Whirlwind", () => { await game.classicMode.startBattle([ Species.STARAPTOR ]); const staraptor = game.scene.getPlayerPokemon()!; - const whirlwind = allMoves[Moves.WHIRLWIND]; - vi.spyOn(whirlwind, "getFailedText"); game.move.select(move); - await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); await game.toNextTurn(); expect(staraptor.findTag((t) => t.tagType === BattlerTagType.FLYING)).toBeDefined(); - expect(whirlwind.getFailedText).toHaveBeenCalledOnce(); + expect(game.scene.getEnemyPokemon()!.getLastXMoves(1)[0].result).toBe(MoveResult.MISS); }); }); From 2c97b2bda2cfdec84a2561d8ff47e9443c6a93cd Mon Sep 17 00:00:00 2001 From: chaosgrimmon <31082757+chaosgrimmon@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:51:29 -0400 Subject: [PATCH 04/16] [Sprite] Fix variant Farigiraf icon names (#4572) --- public/images/pokemon_icons_9v.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/images/pokemon_icons_9v.json b/public/images/pokemon_icons_9v.json index 83a47f101fa..06909a8298f 100644 --- a/public/images/pokemon_icons_9v.json +++ b/public/images/pokemon_icons_9v.json @@ -853,14 +853,14 @@ "spriteSourceSize": { "x": 7, "y": 2, "w": 27, "h": 26 }, "sourceSize": { "w": 40, "h": 30 } }, - "981_2.png": { + "981_2": { "frame": { "x": 108, "y": 87, "w": 23, "h": 30 }, "rotated": false, "trimmed": true, "spriteSourceSize": { "x": 9, "y": 0, "w": 23, "h": 30 }, "sourceSize": { "w": 40, "h": 30 } }, - "981_3.png": { + "981_3": { "frame": { "x": 246, "y": 86, "w": 23, "h": 30 }, "rotated": false, "trimmed": true, From 75bd730c0434d6a96c69171285cd53304015abfe Mon Sep 17 00:00:00 2001 From: AJ Fontaine <36677462+Fontbane@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:55:37 -0400 Subject: [PATCH 05/16] [Balance] Fix TM compatibility on forms, Tera Blast on Indigo Disk mons (#4568) * Fix TM compatibility on forms, Tera Blast on Indigo Disk mons * Additional single strike moves --------- Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/data/balance/tms.ts | 548 +++++++++------------------------------- 1 file changed, 121 insertions(+), 427 deletions(-) diff --git a/src/data/balance/tms.ts b/src/data/balance/tms.ts index e08b677c30c..1a509637e05 100644 --- a/src/data/balance/tms.ts +++ b/src/data/balance/tms.ts @@ -1107,12 +1107,7 @@ export const tmSpecies: TmSpecies = { Species.QUILLADIN, Species.CHESNAUGHT, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.BUNNELBY, Species.DIGGERSBY, Species.FLETCHLING, @@ -2878,12 +2873,7 @@ export const tmSpecies: TmSpecies = { Species.DELPHOX, Species.FROAKIE, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.BUNNELBY, Species.DIGGERSBY, Species.FLETCHLING, @@ -3910,7 +3900,6 @@ export const tmSpecies: TmSpecies = { Species.YAMPER, Species.BOLTUND, Species.ZAMAZENTA, - Species.URSHIFU, Species.ZARUDE, Species.GLASTRIER, Species.WYRDEER, @@ -3940,6 +3929,10 @@ export const tmSpecies: TmSpecies = { Species.ALOLA_NINETALES, Species.ALOLA_PERSIAN, Species.ALOLA_GOLEM, + [ + Species.URSHIFU, + "single-strike", + ], Species.HISUI_GROWLITHE, Species.HISUI_ARCANINE, Species.HISUI_TYPHLOSION, @@ -6987,14 +6980,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -8301,7 +8287,6 @@ export const tmSpecies: TmSpecies = { [ Species.WORMADAM, "sandy", - "trash", ], Species.ALOLA_SANDSHREW, Species.ALOLA_SANDSLASH, @@ -8612,12 +8597,7 @@ export const tmSpecies: TmSpecies = { Species.CHESNAUGHT, Species.FROAKIE, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.BUNNELBY, Species.DIGGERSBY, Species.LITLEO, @@ -9406,14 +9386,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -9766,14 +9739,7 @@ export const tmSpecies: TmSpecies = { Species.DELPHOX, Species.VIVILLON, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.ESPURR, Species.MEOWSTIC, @@ -11147,14 +11113,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -13657,12 +13616,7 @@ export const tmSpecies: TmSpecies = { Species.DELPHOX, Species.FROAKIE, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.FLETCHLING, Species.FLETCHINDER, Species.TALONFLAME, @@ -15326,14 +15280,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -16934,14 +16881,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -18483,14 +18423,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -20250,14 +20183,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -21583,12 +21509,7 @@ export const tmSpecies: TmSpecies = { Species.DELPHOX, Species.FROAKIE, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.BUNNELBY, Species.DIGGERSBY, Species.LITLEO, @@ -22516,7 +22437,6 @@ export const tmSpecies: TmSpecies = { [ Species.WORMADAM, "sandy", - "trash", ], Species.ALOLA_DIGLETT, Species.ALOLA_DUGTRIO, @@ -22691,14 +22611,7 @@ export const tmSpecies: TmSpecies = { Species.CHESNAUGHT, Species.VIVILLON, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -23402,12 +23315,7 @@ export const tmSpecies: TmSpecies = { Species.DELPHOX, Species.FROAKIE, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.BUNNELBY, Species.DIGGERSBY, Species.FLETCHLING, @@ -24134,12 +24042,7 @@ export const tmSpecies: TmSpecies = { Species.KELDEO, Species.FROAKIE, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.PANCHAM, Species.PANGORO, Species.HONEDGE, @@ -24842,14 +24745,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -25712,14 +25608,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -26695,14 +26584,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -27845,14 +27727,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -28911,14 +28786,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -29514,14 +29382,7 @@ export const tmSpecies: TmSpecies = { Species.DELPHOX, Species.VIVILLON, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.ESPURR, Species.MEOWSTIC, @@ -31408,14 +31269,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -32327,14 +32181,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -33037,14 +32884,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -33471,7 +33311,6 @@ export const tmSpecies: TmSpecies = { Species.ARCTOVISH, Species.ZACIAN, Species.ZAMAZENTA, - Species.URSHIFU, Species.ZARUDE, Species.REGIDRAGO, Species.GLASTRIER, @@ -33522,6 +33361,10 @@ export const tmSpecies: TmSpecies = { Species.ALOLA_MUK, Species.GALAR_MEOWTH, Species.GALAR_STUNFISK, + [ + Species.URSHIFU, + "single-strike", + ], [ Species.CALYREX, "ice", @@ -36644,14 +36487,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -37389,14 +37225,7 @@ export const tmSpecies: TmSpecies = { Species.BUNNELBY, Species.DIGGERSBY, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -38266,23 +38095,11 @@ export const tmSpecies: TmSpecies = { Species.DELPHOX, Species.FROAKIE, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -39323,12 +39140,7 @@ export const tmSpecies: TmSpecies = { Species.CHESPIN, Species.QUILLADIN, Species.CHESNAUGHT, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.BUNNELBY, Species.DIGGERSBY, Species.SKIDDO, @@ -40356,7 +40168,10 @@ export const tmSpecies: TmSpecies = { Species.FENNEKIN, Species.BRAIXEN, Species.DELPHOX, - Species.MEOWSTIC, + [ + Species.MEOWSTIC, + "male", + ], Species.KLEFKI, Species.PHANTUMP, Species.TREVENANT, @@ -41805,7 +41620,6 @@ export const tmSpecies: TmSpecies = { [ Species.WORMADAM, "sandy", - "trash", ], Species.ALOLA_SANDSHREW, Species.ALOLA_SANDSLASH, @@ -43701,12 +43515,7 @@ export const tmSpecies: TmSpecies = { Species.DELPHOX, Species.FROAKIE, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.BUNNELBY, Species.DIGGERSBY, Species.SKIDDO, @@ -44160,14 +43969,7 @@ export const tmSpecies: TmSpecies = { Species.QUILLADIN, Species.CHESNAUGHT, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -44377,14 +44179,7 @@ export const tmSpecies: TmSpecies = { Species.DELPHOX, Species.VIVILLON, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.ESPURR, Species.MEOWSTIC, @@ -45256,6 +45051,10 @@ export const tmSpecies: TmSpecies = { Species.IRON_CROWN, Species.TERAPAGOS, Species.ALOLA_EXEGGUTOR, + [ + Species.INDEEDEE, + "male", + ], ], [Moves.GYRO_BALL]: [ Species.SQUIRTLE, @@ -47501,12 +47300,7 @@ export const tmSpecies: TmSpecies = { Species.ACCELGOR, Species.FROAKIE, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.SKRELP, Species.DRAGALGE, Species.MAREANIE, @@ -48143,7 +47937,6 @@ export const tmSpecies: TmSpecies = { Species.RUNERIGUS, Species.MORPEKO, Species.DURALUDON, - Species.URSHIFU, Species.ZARUDE, Species.SPECTRIER, Species.OVERQWIL, @@ -48179,6 +47972,10 @@ export const tmSpecies: TmSpecies = { Species.GALAR_WEEZING, Species.GALAR_MOLTRES, Species.GALAR_YAMASK, + [ + Species.URSHIFU, + "single-strike", + ], [ Species.CALYREX, "shadow", @@ -48456,14 +48253,7 @@ export const tmSpecies: TmSpecies = { Species.QUILLADIN, Species.CHESNAUGHT, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -49622,7 +49412,10 @@ export const tmSpecies: TmSpecies = { Species.TORTERRA, Species.BUDEW, Species.ROSERADE, - Species.WORMADAM, + [ + Species.WORMADAM, + "plant", + ], Species.MOTHIM, Species.CHERUBI, Species.CHERRIM, @@ -49698,14 +49491,7 @@ export const tmSpecies: TmSpecies = { Species.CHESNAUGHT, Species.VIVILLON, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -52635,7 +52421,10 @@ export const tmSpecies: TmSpecies = { Species.TORTERRA, Species.BUDEW, Species.ROSERADE, - Species.WORMADAM, + [ + Species.WORMADAM, + "plant", + ], Species.SNOVER, Species.ABOMASNOW, Species.TANGROWTH, @@ -53751,7 +53540,10 @@ export const tmSpecies: TmSpecies = { Species.BIBAREL, Species.BUDEW, Species.ROSERADE, - Species.WORMADAM, + [ + Species.WORMADAM, + "plant", + ], Species.PACHIRISU, Species.CHERUBI, Species.CHERRIM, @@ -53863,14 +53655,7 @@ export const tmSpecies: TmSpecies = { Species.BUNNELBY, Species.DIGGERSBY, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -55590,12 +55375,7 @@ export const tmSpecies: TmSpecies = { Species.CHESPIN, Species.QUILLADIN, Species.CHESNAUGHT, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.PANCHAM, Species.PANGORO, Species.HELIOPTILE, @@ -55877,7 +55657,6 @@ export const tmSpecies: TmSpecies = { Species.MR_RIME, Species.MORPEKO, Species.DURALUDON, - Species.URSHIFU, Species.SPECTRIER, Species.MEOWSCARADA, Species.SQUAWKABILLY, @@ -55918,6 +55697,10 @@ export const tmSpecies: TmSpecies = { Species.GALAR_MOLTRES, Species.GALAR_SLOWKING, Species.GALAR_STUNFISK, + [ + Species.URSHIFU, + "single-strike", + ], [ Species.CALYREX, "shadow", @@ -56577,14 +56360,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -57019,14 +56795,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.PANCHAM, Species.PANGORO, @@ -57354,14 +57123,7 @@ export const tmSpecies: TmSpecies = { Species.BRAIXEN, Species.DELPHOX, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.ESPURR, Species.MEOWSTIC, @@ -58997,7 +58759,6 @@ export const tmSpecies: TmSpecies = { [ Species.WORMADAM, "sandy", - "trash", ], Species.ALOLA_SANDSHREW, Species.ALOLA_SANDSLASH, @@ -60070,7 +59831,6 @@ export const tmSpecies: TmSpecies = { Species.DURALUDON, Species.ZACIAN, Species.ZAMAZENTA, - Species.URSHIFU, Species.ZARUDE, Species.GLASTRIER, Species.SPECTRIER, @@ -60116,6 +59876,10 @@ export const tmSpecies: TmSpecies = { Species.HISUI_ZOROARK, Species.HISUI_BRAVIARY, Species.BLOODMOON_URSALUNA, + [ + Species.URSHIFU, + "single-strike", + ], ], [Moves.PHANTOM_FORCE]: [ Species.HAUNTER, @@ -60446,14 +60210,7 @@ export const tmSpecies: TmSpecies = { Species.QUILLADIN, Species.CHESNAUGHT, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -60529,16 +60286,12 @@ export const tmSpecies: TmSpecies = { Species.WHIMSICOTT, Species.ALOMOMOLA, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, - Species.MEOWSTIC, + [ + Species.MEOWSTIC, + "male", + ], Species.SPRITZEE, Species.AROMATISSE, Species.SYLVEON, @@ -61402,14 +61155,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -61899,14 +61645,7 @@ export const tmSpecies: TmSpecies = { Species.MELOETTA, Species.DELPHOX, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SPRITZEE, Species.AROMATISSE, @@ -62617,7 +62356,6 @@ export const tmSpecies: TmSpecies = { Species.SIRFETCHD, Species.FALINKS, Species.PINCURCHIN, - Species.URSHIFU, Species.ZARUDE, Species.GLASTRIER, Species.TAROUNTULA, @@ -62647,6 +62385,10 @@ export const tmSpecies: TmSpecies = { Species.GALAR_ZAPDOS, Species.GALAR_CORSOLA, Species.GALAR_LINOONE, + [ + Species.URSHIFU, + "single-strike", + ], [ Species.CALYREX, "ice", @@ -63541,12 +63283,7 @@ export const tmSpecies: TmSpecies = { Species.KELDEO, Species.FROAKIE, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.INKAY, Species.MALAMAR, Species.BINACLE, @@ -64755,7 +64492,6 @@ export const tmSpecies: TmSpecies = { Species.OBSTAGOON, Species.PERRSERKER, Species.MORPEKO, - Species.URSHIFU, Species.ZARUDE, Species.GLASTRIER, Species.SPECTRIER, @@ -64803,6 +64539,10 @@ export const tmSpecies: TmSpecies = { Species.GALAR_LINOONE, Species.GALAR_DARMANITAN, Species.GALAR_STUNFISK, + [ + Species.URSHIFU, + "single-strike", + ], [ Species.CALYREX, "ice", @@ -65933,12 +65673,7 @@ export const tmSpecies: TmSpecies = { Species.DELPHOX, Species.FROAKIE, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.BUNNELBY, Species.DIGGERSBY, Species.FLETCHLING, @@ -65950,14 +65685,7 @@ export const tmSpecies: TmSpecies = { Species.LITLEO, Species.PYROAR, Species.FLABEBE, - [ - Species.FLOETTE, - "red", - "yellow", - "orange", - "blue", - "white", - ], + Species.FLOETTE, Species.FLORGES, Species.SKIDDO, Species.GOGOAT, @@ -66291,6 +66019,13 @@ export const tmSpecies: TmSpecies = { Species.MUNKIDORI, Species.FEZANDIPITI, Species.OGERPON, + Species.ARCHALUDON, + Species.HYDRAPPLE, + Species.GOUGING_FIRE, + Species.RAGING_BOLT, + Species.IRON_BOULDER, + Species.IRON_CROWN, + Species.PECHARUNT, Species.GALAR_MEOWTH, Species.GALAR_SLOWPOKE, Species.GALAR_SLOWBRO, @@ -66429,16 +66164,8 @@ export const tmSpecies: TmSpecies = { Species.PIPLUP, Species.PRINPLUP, Species.EMPOLEON, - [ - Species.SHELLOS, - "east", - "west", - ], - [ - Species.GASTRODON, - "east", - "west", - ], + Species.SHELLOS, + Species.GASTRODON, Species.MISMAGIUS, Species.HAPPINY, Species.SNOVER, @@ -66456,25 +66183,11 @@ export const tmSpecies: TmSpecies = { Species.CUBCHOO, Species.BEARTIC, Species.CRYOGONAL, - [ - Species.TORNADUS, - "incarnate", - "therian", - ], - [ - Species.KYUREM, - "", - "black", - "white", - ], + Species.TORNADUS, + Species.KYUREM, Species.FROAKIE, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.SKRELP, Species.DRAGALGE, Species.BERGMITE, @@ -66482,11 +66195,7 @@ export const tmSpecies: TmSpecies = { Species.DIANCIE, Species.PRIMARINA, Species.CRABOMINABLE, - [ - Species.MAGEARNA, - "", - "original", - ], + Species.MAGEARNA, Species.INTELEON, Species.FROSMOTH, Species.EISCUE, @@ -66779,12 +66488,7 @@ export const tmSpecies: TmSpecies = { Species.CHESNAUGHT, Species.FROAKIE, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.LITLEO, Species.PYROAR, Species.FLABEBE, @@ -67007,23 +66711,14 @@ export const tmSpecies: TmSpecies = { Species.WEAVILE, Species.GLACEON, Species.FROSLASS, - [ - Species.PALKIA, - "", - "origin", - ], + Species.PALKIA, Species.PHIONE, Species.MANAPHY, Species.ARCEUS, Species.OSHAWOTT, Species.DEWOTT, Species.SAMUROTT, - [ - Species.BASCULIN, - "red-striped", - "blue-striped", - "white-striped", - ], + Species.BASCULIN, Species.MINCCINO, Species.CINCCINO, Species.DUCKLETT, @@ -67036,12 +66731,7 @@ export const tmSpecies: TmSpecies = { Species.KELDEO, Species.FROAKIE, Species.FROGADIER, - [ - Species.GRENINJA, - "", - "battle-bond", - "ash", - ], + Species.GRENINJA, Species.FLABEBE, Species.FLOETTE, Species.FLORGES, @@ -67304,6 +66994,10 @@ export const tmSpecies: TmSpecies = { Species.FEZANDIPITI, Species.ALOLA_RAICHU, Species.ETERNAL_FLOETTE, + [ + Species.INDEEDEE, + "female", + ], ], [Moves.TEMPER_FLARE]: [ Species.CHARMANDER, 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 06/16] [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 f633d427d6d..ee2708b38f5 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 abe443cdfa6..b75d23b48d8 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 2225a457a42..62ac36b28ad 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 c484b2932f1..0ab0d76e880 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 05567491a1a..c2ef7d919b0 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 807f194bad5..6272358aa85 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 00000000000..93fcf57cc60 --- /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 07/16] [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 6cb33dca306..a54a8c5f519 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 62ac36b28ad..8095b5a6013 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 ccd6e9fe314..43c849a78e0 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 c2ef7d919b0..94fa050a7bc 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 00000000000..5d15a825688 --- /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 08/16] [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 8095b5a6013..01b300cbae4 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 00000000000..6e5be2a790d --- /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 09/16] [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 5cc70abf143..98a563301e4 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 10/16] [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 b75d23b48d8..6407e139a71 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 01b300cbae4..f795d265336 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 0ab0d76e880..c73f4ec2ae5 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 94fa050a7bc..d6f73e1b5bc 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; } From f562a76332fcf76601ed215b48b11e2b484efaef Mon Sep 17 00:00:00 2001 From: Xavion3 Date: Sat, 5 Oct 2024 17:10:32 +1000 Subject: [PATCH 11/16] Make repeat abilities not stack (#4588) If due to fusions you have the same ability as both passive and normal, it'll no longer stack with itself. --- src/data/ability.ts | 2 +- .../abilities/ability_duplication.test.ts | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/test/abilities/ability_duplication.test.ts diff --git a/src/data/ability.ts b/src/data/ability.ts index 62e6e772411..43d02da1733 100644 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -4623,7 +4623,7 @@ async function applyAbAttrsInternal( messages: string[] = [], ) { for (const passive of [ false, true ]) { - if (!pokemon?.canApplyAbility(passive)) { + if (!pokemon?.canApplyAbility(passive) || (passive && pokemon.getPassiveAbility().id === pokemon.getAbility().id)) { continue; } diff --git a/src/test/abilities/ability_duplication.test.ts b/src/test/abilities/ability_duplication.test.ts new file mode 100644 index 00000000000..f9122b3259c --- /dev/null +++ b/src/test/abilities/ability_duplication.test.ts @@ -0,0 +1,58 @@ +import { Stat } from "#app/enums/stat"; +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 } from "vitest"; + +describe("Ability Duplication", () => { + 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.SPLASH ]) + .battleType("single") + .ability(Abilities.HUGE_POWER) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("huge power should only be applied once if both normal and passive", async () => { + game.override.passiveAbility(Abilities.HUGE_POWER); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const [ magikarp ] = game.scene.getPlayerField(); + const magikarpAttack = magikarp.getEffectiveStat(Stat.ATK); + + magikarp.summonData.abilitySuppressed = true; + + expect(magikarp.getEffectiveStat(Stat.ATK)).toBe(magikarpAttack / 2); + }); + + it("huge power should stack with pure power", async () => { + game.override.passiveAbility(Abilities.PURE_POWER); + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const [ magikarp ] = game.scene.getPlayerField(); + const magikarpAttack = magikarp.getEffectiveStat(Stat.ATK); + + magikarp.summonData.abilitySuppressed = true; + + expect(magikarp.getEffectiveStat(Stat.ATK)).toBe(magikarpAttack / 4); + }); +}); From 42b75e8440c8b298f0ee7f404f7918f499da6574 Mon Sep 17 00:00:00 2001 From: flx-sta <50131232+flx-sta@users.noreply.github.com> Date: Sat, 5 Oct 2024 14:01:41 -0700 Subject: [PATCH 12/16] [Qol] Make i18n money formatter controlled by translators (#4550) * fix: i18n money formatter * fix wrongful console.warn on i18n money formatter * update locales submodule update reference to `56eeb809eb5a2de40cfc5bc6128a78bef14deea9` (from `3ccef8472dd7cc7c362538489954cb8fdad27e5f`) --- public/locales | 2 +- src/plugins/i18n.ts | 38 ++++++++++++++++++++------------------ 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/public/locales b/public/locales index 3ccef8472dd..56eeb809eb5 160000 --- a/public/locales +++ b/public/locales @@ -1 +1 @@ -Subproject commit 3ccef8472dd7cc7c362538489954cb8fdad27e5f +Subproject commit 56eeb809eb5a2de40cfc5bc6128a78bef14deea9 diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts index 676e47c19b8..be4c6983c0a 100644 --- a/src/plugins/i18n.ts +++ b/src/plugins/i18n.ts @@ -100,6 +100,22 @@ async function initFonts(language: string | undefined) { } } +/** + * I18n money formatter with. (useful for BBCode coloring of text)\ + * *If you don't want the BBCode tag applied, just use 'number' formatter* + * @example Input: `{{myMoneyValue, money}}` + * Output: `@[MONEY]{₽100,000,000}` + * @param amount the money amount + * @returns a money formatted string + */ +function i18nMoneyFormatter(amount: any): string { + if (isNaN(Number(amount))) { + console.warn(`i18nMoneyFormatter: value "${amount}" is not a number!`); + } + + return `@[MONEY]{${i18next.t("common:money", { amount })}}`; +} + //#region Exports /** @@ -249,24 +265,10 @@ export async function initI18n(): Promise { postProcess: [ "korean-postposition" ], }); - // Input: {{myMoneyValue, money}} - // Output: @[MONEY]{₽100,000,000} (useful for BBCode coloring of text) - // If you don't want the BBCode tag applied, just use 'number' formatter - i18next.services.formatter?.add("money", (value, lng, options) => { - const numberFormattedString = Intl.NumberFormat(lng, options).format(value); - switch (lng) { - case "ja": - return `@[MONEY]{${numberFormattedString}}円`; - case "de": - case "es": - case "fr": - case "it": - return `@[MONEY]{${numberFormattedString} ₽}`; - default: - // English and other languages that use same format - return `@[MONEY]{₽${numberFormattedString}}`; - } - }); + + if (i18next.services.formatter) { + i18next.services.formatter.add("money", i18nMoneyFormatter); + } await initFonts(localStorage.getItem("prLang") ?? undefined); } From e8f40c10c948606e61a6660d8978e17e92491f4a Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Sat, 5 Oct 2024 16:52:13 -0700 Subject: [PATCH 13/16] [Test] Update `create-test` script for linting changes (#4587) Add additional boilerplate code Change prompt to be slightly more accurate Co-authored-by: Adrian T. <68144167+torranx@users.noreply.github.com> --- create-test-boilerplate.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/create-test-boilerplate.js b/create-test-boilerplate.js index 5f4bbc41198..a365999c623 100644 --- a/create-test-boilerplate.js +++ b/create-test-boilerplate.js @@ -49,7 +49,7 @@ async function promptFileName(selectedType) { { type: "input", name: "userInput", - message: `Please provide a file name for the ${selectedType} test:`, + message: `Please provide the name of the ${selectedType}:`, }, ]); @@ -110,7 +110,7 @@ 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 } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; describe("${description}", () => { let phaserGame: Phaser.Game; @@ -129,15 +129,22 @@ describe("${description}", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset([Moves.SPLASH]) + .moveset([ Moves.SPLASH ]) + .ability(Abilities.BALL_FETCH) .battleType("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) .enemyAbility(Abilities.BALL_FETCH) .enemyMoveset(Moves.SPLASH); }); - it("test case", async () => { - // await game.classicMode.startBattle([Species.MAGIKARP]); - // game.move.select(Moves.SPLASH); + it("should do X", async () => { + await game.classicMode.startBattle([ Species.FEEBAS ]); + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("BerryPhase"); + + expect(true).toBe(true); }); }); `; From f629a3e45332176bee4db0ba5d03c31b80c6ffbf Mon Sep 17 00:00:00 2001 From: innerthunder <168692175+innerthunder@users.noreply.github.com> Date: Sat, 5 Oct 2024 16:52:53 -0700 Subject: [PATCH 14/16] [P2] Stop G-Max Pokemon from evolving (#4581) --- src/field/pokemon.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index d6f73e1b5bc..a0ad4e8a52f 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1707,7 +1707,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (pokemonEvolutions.hasOwnProperty(this.species.speciesId)) { const evolutions = pokemonEvolutions[this.species.speciesId]; for (const e of evolutions) { - if (!e.item && this.level >= e.level && (!e.preFormKey || this.getFormKey() === e.preFormKey)) { + if (!e.item && this.level >= e.level && (isNullOrUndefined(e.preFormKey) || this.getFormKey() === e.preFormKey)) { if (e.condition === null || (e.condition as SpeciesEvolutionCondition).predicate(this)) { return e; } @@ -1718,7 +1718,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (this.isFusion() && this.fusionSpecies && pokemonEvolutions.hasOwnProperty(this.fusionSpecies.speciesId)) { const fusionEvolutions = pokemonEvolutions[this.fusionSpecies.speciesId].map(e => new FusionSpeciesFormEvolution(this.species.speciesId, e)); for (const fe of fusionEvolutions) { - if (!fe.item && this.level >= fe.level && (!fe.preFormKey || this.getFusionFormKey() === fe.preFormKey)) { + if (!fe.item && this.level >= fe.level && (isNullOrUndefined(fe.preFormKey) || this.getFusionFormKey() === fe.preFormKey)) { if (fe.condition === null || (fe.condition as SpeciesEvolutionCondition).predicate(this)) { return fe; } From c2c41d9be8b6f73b50ae22024280b8dcaffbdb9f Mon Sep 17 00:00:00 2001 From: Frederico Santos Date: Sun, 6 Oct 2024 02:49:03 +0100 Subject: [PATCH 15/16] Update subproject commit reference for locales --- public/locales | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/locales b/public/locales index 56eeb809eb5..b44ee217378 160000 --- a/public/locales +++ b/public/locales @@ -1 +1 @@ -Subproject commit 56eeb809eb5a2de40cfc5bc6128a78bef14deea9 +Subproject commit b44ee2173788018ffd5dc6b7b7fa159be5b9d514 From f9691b872b38e615cb46b09aa06a9dfd7d0f81c8 Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Sat, 5 Oct 2024 21:47:34 -0700 Subject: [PATCH 16/16] Change deploy script to specify "main" instead of `default_branch` (#4557) --- .github/workflows/deploy.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 70dc2bfa502..e40b18eb69b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,8 +1,12 @@ -name: Deploy +name: Deploy Main on: - push: {} - pull_request: {} + push: + branches: + - main + pull_request: + branches: + - main jobs: deploy: @@ -22,7 +26,7 @@ jobs: env: NODE_ENV: production - name: Set up SSH - if: github.event_name == 'push' && github.ref_name == github.event.repository.default_branch + if: github.event_name == 'push' && github.ref_name == 'main' run: | mkdir ~/.ssh echo "${{ secrets.SSH_PUBLIC_KEY }}" > ~/.ssh/id_ed25519.pub @@ -30,12 +34,12 @@ jobs: chmod 600 ~/.ssh/* ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts - name: Deploy build on server - if: github.event_name == 'push' && github.ref_name == github.event.repository.default_branch + if: github.event_name == 'push' && github.ref_name == 'main' run: | rsync --del --no-times --checksum -vrm dist/* ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ secrets.DESTINATION_DIR }} ssh -t ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "~/prmanifest --inpath ${{ secrets.DESTINATION_DIR }} --outpath ${{ secrets.DESTINATION_DIR }}/manifest.json" - name: Purge Cloudflare Cache - if: github.event_name == 'push' && github.ref_name == github.event.repository.default_branch + if: github.event_name == 'push' && github.ref_name == 'main' id: purge-cache uses: NathanVaughn/actions-cloudflare-purge@v3.1.0 with: