From 5a24435047b4bbd98f5b21b0828560a80378ba3d Mon Sep 17 00:00:00 2001 From: Michael Li Date: Sat, 21 Sep 2024 11:49:21 -0400 Subject: [PATCH 1/7] [Bug] Fix game freeze from GTS infinite loop --- .../encounters/global-trade-system-encounter.ts | 13 ++++++------- .../global-trade-system-encounter.test.ts | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts b/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts index 55d4953d438..95e12339913 100644 --- a/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts +++ b/src/data/mystery-encounters/encounters/global-trade-system-encounter.ts @@ -431,14 +431,13 @@ function getPokemonTradeOptions(scene: BattleScene): Map function generateTradeOption(alreadyUsedSpecies: PokemonSpecies[], originalBst?: number): PokemonSpecies { let newSpecies: PokemonSpecies | undefined; + let bstCap = 9999; + let bstMin = 0; + if (originalBst) { + bstCap = originalBst + 100; + bstMin = originalBst - 100; + } while (isNullOrUndefined(newSpecies)) { - let bstCap = 9999; - let bstMin = 0; - if (originalBst) { - bstCap = originalBst + 100; - bstMin = originalBst - 100; - } - // Get all non-legendary species that fall within the Bst range requirements let validSpecies = allSpecies .filter(s => { diff --git a/src/test/mystery-encounter/encounters/global-trade-system-encounter.test.ts b/src/test/mystery-encounter/encounters/global-trade-system-encounter.test.ts index 5a99b0450ca..9ccde209827 100644 --- a/src/test/mystery-encounter/encounters/global-trade-system-encounter.test.ts +++ b/src/test/mystery-encounter/encounters/global-trade-system-encounter.test.ts @@ -69,6 +69,20 @@ describe("Global Trade System - Mystery Encounter", () => { expect(GlobalTradeSystemEncounter.options.length).toBe(4); }); + it("should not freeze when generating trade options for extreme BST non-legendaries", async () => { + const extremeBstTeam = [Species.SLAKING, Species.WISHIWASHI, Species.SUNKERN]; + await game.runToMysteryEncounter(MysteryEncounterType.GLOBAL_TRADE_SYSTEM, extremeBstTeam); + + expect(GlobalTradeSystemEncounter.encounterType).toBe(MysteryEncounterType.GLOBAL_TRADE_SYSTEM); + expect(GlobalTradeSystemEncounter.encounterTier).toBe(MysteryEncounterTier.COMMON); + expect(GlobalTradeSystemEncounter.dialogue).toBeDefined(); + expect(GlobalTradeSystemEncounter.dialogue.intro).toStrictEqual([{ text: `${namespace}.intro` }]); + expect(GlobalTradeSystemEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}.title`); + expect(GlobalTradeSystemEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}.description`); + expect(GlobalTradeSystemEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}.query`); + expect(GlobalTradeSystemEncounter.options.length).toBe(4); + }); + it("should not spawn outside of CIVILIZATION_ENCOUNTER_BIOMES", async () => { game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); game.override.startingBiome(Biome.VOLCANO); From 7190277f79cca138de51007b32de44c907ea7776 Mon Sep 17 00:00:00 2001 From: Michael Li Date: Sat, 21 Sep 2024 14:36:02 -0400 Subject: [PATCH 2/7] PR Feedback --- .../encounters/global-trade-system-encounter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/mystery-encounter/encounters/global-trade-system-encounter.test.ts b/src/test/mystery-encounter/encounters/global-trade-system-encounter.test.ts index 9ccde209827..ec35b338365 100644 --- a/src/test/mystery-encounter/encounters/global-trade-system-encounter.test.ts +++ b/src/test/mystery-encounter/encounters/global-trade-system-encounter.test.ts @@ -69,7 +69,7 @@ describe("Global Trade System - Mystery Encounter", () => { expect(GlobalTradeSystemEncounter.options.length).toBe(4); }); - it("should not freeze when generating trade options for extreme BST non-legendaries", async () => { + it("should not loop infinitely when generating trade options for extreme BST non-legendaries", async () => { const extremeBstTeam = [Species.SLAKING, Species.WISHIWASHI, Species.SUNKERN]; await game.runToMysteryEncounter(MysteryEncounterType.GLOBAL_TRADE_SYSTEM, extremeBstTeam); From ba5e89cf6114626f70a4341aa6c3c7bb7987d45d Mon Sep 17 00:00:00 2001 From: Blitzy <118096277+Blitz425@users.noreply.github.com> Date: Sat, 21 Sep 2024 14:15:02 -0500 Subject: [PATCH 3/7] Swap Order in Trainer Config (#4367) --- src/data/trainer-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/trainer-config.ts b/src/data/trainer-config.ts index 63ea1ed463d..62f9589b7a3 100644 --- a/src/data/trainer-config.ts +++ b/src/data/trainer-config.ts @@ -2261,8 +2261,8 @@ export const trainerConfigs: TrainerConfigs = { [TrainerType.PENNY_2]: new TrainerConfig(++t).setName("Cassiopeia").initForEvilTeamLeader("Star Boss", [], true).setMixedBattleBgm("battle_star_boss").setVictoryBgm("victory_team_plasma") .setPartyMemberFunc(0, getRandomPartyMemberFunc([ Species.REVAVROOM ], TrainerSlot.TRAINER, true, p => { p.setBoss(true, 2); - p.generateAndPopulateMoveset(); p.formIndex = Utils.randSeedInt(5, 1); //Random Starmobile form + p.generateAndPopulateMoveset(); p.pokeball = PokeballType.ULTRA_BALL; })) .setPartyMemberFunc(1, getRandomPartyMemberFunc([ Species.ENTEI, Species.RAIKOU, Species.SUICUNE ], TrainerSlot.TRAINER, true, p => { From 1e70cb92d10d9623744a43656d77dbc9eb87bd40 Mon Sep 17 00:00:00 2001 From: Jannik Tappert <38758606+CodeTappert@users.noreply.github.com> Date: Sat, 21 Sep 2024 21:39:44 +0200 Subject: [PATCH 4/7] [BUG] Add enough keys in dialogue.ts for team star (#4362) --- src/data/dialogue.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/data/dialogue.ts b/src/data/dialogue.ts index b39e45ce632..499cd106cf9 100644 --- a/src/data/dialogue.ts +++ b/src/data/dialogue.ts @@ -867,9 +867,17 @@ export const trainerTypeDialogue: TrainerTypeDialogue = { { encounter: [ "dialogue:star_grunt.encounter.1", + "dialogue:star_grunt.encounter.2", + "dialogue:star_grunt.encounter.3", + "dialogue:star_grunt.encounter.4", + "dialogue:star_grunt.encounter.5", ], victory: [ "dialogue:star_grunt.victory.1", + "dialogue:star_grunt.victory.2", + "dialogue:star_grunt.victory.3", + "dialogue:star_grunt.victory.4", + "dialogue:star_grunt.victory.5", ] } ], @@ -877,9 +885,11 @@ export const trainerTypeDialogue: TrainerTypeDialogue = { { encounter: [ "dialogue:giacomo.encounter.1", + "dialogue:giacomo.encounter.2", ], victory: [ "dialogue:giacomo.victory.1", + "dialogue:giacomo.victory.2", ] } ], @@ -887,9 +897,11 @@ export const trainerTypeDialogue: TrainerTypeDialogue = { { encounter: [ "dialogue:mela.encounter.1", + "dialogue:mela.encounter.2", ], victory: [ "dialogue:mela.victory.1", + "dialogue:mela.victory.2", ] } ], @@ -897,9 +909,11 @@ export const trainerTypeDialogue: TrainerTypeDialogue = { { encounter: [ "dialogue:atticus.encounter.1", + "dialogue:atticus.encounter.2", ], victory: [ "dialogue:atticus.victory.1", + "dialogue:atticus.victory.2", ] } ], @@ -907,9 +921,11 @@ export const trainerTypeDialogue: TrainerTypeDialogue = { { encounter: [ "dialogue:ortega.encounter.1", + "dialogue:ortega.encounter.2", ], victory: [ "dialogue:ortega.victory.1", + "dialogue:ortega.victory.2", ] } ], @@ -917,9 +933,11 @@ export const trainerTypeDialogue: TrainerTypeDialogue = { { encounter: [ "dialogue:eri.encounter.1", + "dialogue:eri.encounter.2", ], victory: [ "dialogue:eri.victory.1", + "dialogue:eri.victory.2", ] } ], From 4fb76fd1172e4745ce2c52b5608a68d6e9d149ae Mon Sep 17 00:00:00 2001 From: Mumble <171087428+frutescens@users.noreply.github.com> Date: Sat, 21 Sep 2024 13:15:32 -0700 Subject: [PATCH 5/7] [Move] Implement Heal Block (#4120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Heal Block on new branch * Add/update code from previous PR * Re-add i18n from previous PR Co-authored-by: Lugiad' Co-authored-by: José Ricardo Fleury Oliveira Co-authored-by: Jannik Tappert <38758606+CodeTappert@users.noreply.github.com> Co-authored-by: Asdar * Disabling Moves * Still need to update tests and write docs * removing partial tags from abilities to feel better * Pollen Puff works now * Implemented Psychic Noise * typedocs * Documentation * Update src/data/battler-tags.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Update src/data/battler-tags.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Update src/data/battler-tags.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> * Turns out the old condition wasn't buggy. Probably mixed up another change I made. * changed array clear --------- Co-authored-by: frutescens Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> Co-authored-by: Lugiad' Co-authored-by: José Ricardo Fleury Oliveira Co-authored-by: Jannik Tappert <38758606+CodeTappert@users.noreply.github.com> Co-authored-by: Asdar --- src/data/ability.ts | 12 +-- src/data/battler-tags.ts | 84 +++++++++++++++- src/data/move.ts | 7 +- src/enums/battler-tag-type.ts | 1 + src/field/pokemon.ts | 26 ++++- src/locales/de/battle.json | 4 +- src/locales/en/battle.json | 4 +- src/locales/es/battle.json | 4 +- src/locales/fr/battle.json | 4 +- src/locales/pt_BR/battle.json | 4 +- src/phases/pokemon-heal-phase.ts | 9 +- src/phases/select-target-phase.ts | 10 ++ src/test/moves/heal_block.test.ts | 153 ++++++++++++++++++++++++++++++ 13 files changed, 302 insertions(+), 20 deletions(-) create mode 100644 src/test/moves/heal_block.test.ts diff --git a/src/data/ability.ts b/src/data/ability.ts index 58233e9839e..06eb8725f76 100755 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -4831,11 +4831,9 @@ export function initAbilities() { .bypassFaint(), new Ability(Abilities.VOLT_ABSORB, 3) .attr(TypeImmunityHealAbAttr, Type.ELECTRIC) - .partial() // Healing not blocked by Heal Block .ignorable(), new Ability(Abilities.WATER_ABSORB, 3) .attr(TypeImmunityHealAbAttr, Type.WATER) - .partial() // Healing not blocked by Heal Block .ignorable(), new Ability(Abilities.OBLIVIOUS, 3) .attr(BattlerTagImmunityAbAttr, BattlerTagType.INFATUATED) @@ -4948,8 +4946,7 @@ export function initAbilities() { .attr(MoveImmunityAbAttr, (pokemon, attacker, move) => pokemon !== attacker && move.hasFlag(MoveFlags.SOUND_BASED)) .ignorable(), new Ability(Abilities.RAIN_DISH, 3) - .attr(PostWeatherLapseHealAbAttr, 1, WeatherType.RAIN, WeatherType.HEAVY_RAIN) - .partial(), // Healing not blocked by Heal Block + .attr(PostWeatherLapseHealAbAttr, 1, WeatherType.RAIN, WeatherType.HEAVY_RAIN), new Ability(Abilities.SAND_STREAM, 3) .attr(PostSummonWeatherChangeAbAttr, WeatherType.SANDSTORM) .attr(PostBiomeChangeWeatherChangeAbAttr, WeatherType.SANDSTORM), @@ -5080,7 +5077,6 @@ export function initAbilities() { .attr(PostWeatherLapseHealAbAttr, 2, WeatherType.RAIN, WeatherType.HEAVY_RAIN) .attr(ReceivedTypeDamageMultiplierAbAttr, Type.FIRE, 1.25) .attr(TypeImmunityHealAbAttr, Type.WATER) - .partial() // Healing not blocked by Heal Block .ignorable(), new Ability(Abilities.DOWNLOAD, 4) .attr(DownloadAbAttr), @@ -5161,8 +5157,7 @@ export function initAbilities() { .ignorable(), new Ability(Abilities.ICE_BODY, 4) .attr(BlockWeatherDamageAttr, WeatherType.HAIL) - .attr(PostWeatherLapseHealAbAttr, 1, WeatherType.HAIL, WeatherType.SNOW) - .partial(), // Healing not blocked by Heal Block + .attr(PostWeatherLapseHealAbAttr, 1, WeatherType.HAIL, WeatherType.SNOW), new Ability(Abilities.SOLID_ROCK, 4) .attr(ReceivedMoveDamageMultiplierAbAttr, (target, user, move) => target.getMoveEffectiveness(user, move) >= 2, 0.75) .ignorable(), @@ -5332,8 +5327,7 @@ export function initAbilities() { .ignorable() .unimplemented(), new Ability(Abilities.CHEEK_POUCH, 6) - .attr(HealFromBerryUseAbAttr, 1/3) - .partial(), // Healing not blocked by Heal Block + .attr(HealFromBerryUseAbAttr, 1/3), new Ability(Abilities.PROTEAN, 6) .attr(PokemonTypeChangeAbAttr), //.condition((p) => !p.summonData?.abilitiesApplied.includes(Abilities.PROTEAN)), //Gen 9 Implementation diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 32b3d54f302..3be6562307b 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -3,7 +3,7 @@ import { getPokemonNameWithAffix } from "../messages"; import Pokemon, { MoveResult, HitResult } from "../field/pokemon"; import { StatusEffect } from "./status-effect"; import * as Utils from "../utils"; -import { ChargeAttr, MoveFlags, allMoves } from "./move"; +import { ChargeAttr, MoveFlags, allMoves, MoveCategory, applyMoveAttrs, StatusCategoryOnAllyAttr, HealOnAllyAttr } from "./move"; import { Type } from "./type"; import { BlockNonDirectDamageAbAttr, FlinchEffectAbAttr, ReverseDrainAbAttr, applyAbAttrs, ProtectStatAbAttr } from "./ability"; import { TerrainType } from "./terrain"; @@ -141,6 +141,18 @@ export abstract class MoveRestrictionBattlerTag extends BattlerTag { */ abstract isMoveRestricted(move: Moves): boolean; + /** + * Checks if this tag is restricting a move based on a user's decisions during the target selection phase + * + * @param {Moves} move {@linkcode Moves} move ID to check restriction for + * @param {Pokemon} user {@linkcode Pokemon} the user of the above move + * @param {Pokemon} target {@linkcode Pokemon} the target of the above move + * @returns {boolean} `false` unless overridden by the child tag + */ + isMoveTargetRestricted(move: Moves, user: Pokemon, target: Pokemon): boolean { + return false; + } + /** * Gets the text to display when the player attempts to select a move that is restricted by this tag. * @@ -2178,6 +2190,74 @@ export class ExposedTag extends BattlerTag { } } +/** + * Tag that prevents HP recovery from held items and move effects. It also blocks the usage of recovery moves. + * Applied by moves: {@linkcode Moves.HEAL_BLOCK | Heal Block (5 turns)}, {@linkcode Moves.PSYCHIC_NOISE | Psychic Noise (2 turns)} + * + * @extends MoveRestrictionBattlerTag + */ +export class HealBlockTag extends MoveRestrictionBattlerTag { + constructor(turnCount: number, sourceMove: Moves) { + super(BattlerTagType.HEAL_BLOCK, [ BattlerTagLapseType.PRE_MOVE, BattlerTagLapseType.TURN_END ], turnCount, sourceMove); + } + + onActivation(pokemon: Pokemon): string { + return i18next.t("battle:battlerTagsHealBlock", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }); + } + + /** + * Checks if a move is disabled under Heal Block + * @param {Moves} move {@linkcode Moves} the move ID + * @returns `true` if the move has a TRIAGE_MOVE flag and is a status move + */ + override isMoveRestricted(move: Moves): boolean { + if (allMoves[move].hasFlag(MoveFlags.TRIAGE_MOVE) && allMoves[move].category === MoveCategory.STATUS) { + return true; + } + return false; + } + + /** + * Checks if a move is disabled under Heal Block because of its choice of target + * Implemented b/c of Pollen Puff + * @param {Moves} move {@linkcode Moves} the move ID + * @param {Pokemon} user {@linkcode Pokemon} the move user + * @param {Pokemon} target {@linkcode Pokemon} the target of the move + * @returns `true` if the move cannot be used because the target is an ally + */ + override isMoveTargetRestricted(move: Moves, user: Pokemon, target: Pokemon) { + const moveCategory = new Utils.IntegerHolder(allMoves[move].category); + applyMoveAttrs(StatusCategoryOnAllyAttr, user, target, allMoves[move], moveCategory); + if (allMoves[move].hasAttr(HealOnAllyAttr) && moveCategory.value === MoveCategory.STATUS ) { + return true; + } + return false; + } + + /** + * Uses DisabledTag's selectionDeniedText() message + */ + override selectionDeniedText(pokemon: Pokemon, move: Moves): string { + return i18next.t("battle:moveDisabled", { moveName: allMoves[move].name }); + } + + /** + * @override + * @param {Pokemon} pokemon {@linkcode Pokemon} attempting to use the restricted move + * @param {Moves} move {@linkcode Moves} ID of the move being interrupted + * @returns {string} text to display when the move is interrupted + */ + override interruptedText(pokemon: Pokemon, move: Moves): string { + return i18next.t("battle:disableInterruptedMove", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), moveName: allMoves[move].name }); + } + + override onRemove(pokemon: Pokemon): void { + super.onRemove(pokemon); + + pokemon.scene.queueMessage(i18next.t("battle:battlerTagsHealBlockOnRemove", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) }), null, false, null); + } +} + /** * Tag that doubles the type effectiveness of Fire-type moves. * @extends BattlerTag @@ -2490,6 +2570,8 @@ export function getBattlerTag(tagType: BattlerTagType, turnCount: number, source return new SubstituteTag(sourceMove, sourceId); case BattlerTagType.MYSTERY_ENCOUNTER_POST_SUMMON: return new MysteryEncounterPostSummonTag(); + case BattlerTagType.HEAL_BLOCK: + return new HealBlockTag(turnCount, sourceMove); case BattlerTagType.NONE: default: return new BattlerTag(tagType, BattlerTagLapseType.CUSTOM, turnCount, sourceMove, sourceId); diff --git a/src/data/move.ts b/src/data/move.ts index de5176c3c84..a84bf38c5ea 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -4539,6 +4539,7 @@ export class AddBattlerTagAttr extends MoveEffectAttr { case BattlerTagType.NIGHTMARE: case BattlerTagType.DROWSY: case BattlerTagType.DISABLED: + case BattlerTagType.HEAL_BLOCK: return -5; case BattlerTagType.SEEDED: case BattlerTagType.SALT_CURED: @@ -7826,8 +7827,8 @@ export function initMoves() { .makesContact() .attr(LessPPMorePowerAttr), new StatusMove(Moves.HEAL_BLOCK, Type.PSYCHIC, 100, 15, -1, 0, 4) - .target(MoveTarget.ALL_NEAR_ENEMIES) - .unimplemented(), + .attr(AddBattlerTagAttr, BattlerTagType.HEAL_BLOCK, false, true, 5) + .target(MoveTarget.ALL_NEAR_ENEMIES), new AttackMove(Moves.WRING_OUT, Type.NORMAL, MoveCategory.SPECIAL, -1, 100, 5, -1, 0, 4) .attr(OpponentHighHpPowerAttr, 120) .makesContact(), @@ -9609,7 +9610,7 @@ export function initMoves() { .recklessMove(), new AttackMove(Moves.PSYCHIC_NOISE, Type.PSYCHIC, MoveCategory.SPECIAL, 75, 100, 10, -1, 0, 9) .soundBased() - .partial(), + .attr(AddBattlerTagAttr, BattlerTagType.HEAL_BLOCK, false, false, 2), new AttackMove(Moves.UPPER_HAND, Type.FIGHTING, MoveCategory.PHYSICAL, 65, 100, 15, 100, 3, 9) .attr(FlinchAttr) .condition((user, target, move) => user.scene.currentBattle.turnCommands[target.getBattlerIndex()]?.command === Command.FIGHT && !target.turnData.acted && allMoves[user.scene.currentBattle.turnCommands[target.getBattlerIndex()]?.move?.move!].category !== MoveCategory.STATUS && allMoves[user.scene.currentBattle.turnCommands[target.getBattlerIndex()]?.move?.move!].priority > 0 ) // TODO: is this bang correct? diff --git a/src/enums/battler-tag-type.ts b/src/enums/battler-tag-type.ts index f367b1b56c0..6cf2d260dcb 100644 --- a/src/enums/battler-tag-type.ts +++ b/src/enums/battler-tag-type.ts @@ -80,4 +80,5 @@ export enum BattlerTagType { BURNED_UP = "BURNED_UP", DOUBLE_SHOCKED = "DOUBLE_SHOCKED", MYSTERY_ENCOUNTER_POST_SUMMON = "MYSTERY_ENCOUNTER_POST_SUMMON", + HEAL_BLOCK = "HEAL_BLOCK", } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 52cd995f473..d2db40d0e98 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2971,16 +2971,40 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return this.getRestrictingTag(moveId) !== null; } + /** + * Gets whether the given move is currently disabled for the user based on the player's target selection + * + * @param {Moves} moveId {@linkcode Moves} ID of the move to check + * @param {Pokemon} user {@linkcode Pokemon} the move user + * @param {Pokemon} target {@linkcode Pokemon} the target of the move + * + * @returns {boolean} `true` if the move is disabled for this Pokemon due to the player's target selection + * + * @see {@linkcode MoveRestrictionBattlerTag} + */ + isMoveTargetRestricted(moveId: Moves, user: Pokemon, target: Pokemon): boolean { + for (const tag of this.findTags(t => t instanceof MoveRestrictionBattlerTag)) { + if ((tag as MoveRestrictionBattlerTag).isMoveTargetRestricted(moveId, user, target)) { + return (tag as MoveRestrictionBattlerTag !== null); + } + } + return false; + } + /** * Gets the {@link MoveRestrictionBattlerTag} that is restricting a move, if it exists. * * @param {Moves} moveId {@linkcode Moves} ID of the move to check + * @param {Pokemon} user {@linkcode Pokemon} the move user, optional and used when the target is a factor in the move's restricted status + * @param {Pokemon} target {@linkcode Pokemon} the target of the move, optional and used when the target is a factor in the move's restricted status * @returns {MoveRestrictionBattlerTag | null} the first tag on this Pokemon that restricts the move, or `null` if the move is not restricted. */ - getRestrictingTag(moveId: Moves): MoveRestrictionBattlerTag | null { + getRestrictingTag(moveId: Moves, user?: Pokemon, target?: Pokemon): MoveRestrictionBattlerTag | null { for (const tag of this.findTags(t => t instanceof MoveRestrictionBattlerTag)) { if ((tag as MoveRestrictionBattlerTag).isMoveRestricted(moveId)) { return tag as MoveRestrictionBattlerTag; + } else if (user && target && (tag as MoveRestrictionBattlerTag).isMoveTargetRestricted(moveId, user, target)) { + return tag as MoveRestrictionBattlerTag; } } return null; diff --git a/src/locales/de/battle.json b/src/locales/de/battle.json index 38e36d4b2da..7a9c2570211 100644 --- a/src/locales/de/battle.json +++ b/src/locales/de/battle.json @@ -96,5 +96,7 @@ "unlockedSomething": "{{unlockedThing}} wurde freigeschaltet.", "congratulations": "Glückwunsch!", "beatModeFirstTime": "{{speciesName}} hat den {{gameMode}} Modus zum ersten Mal beendet! Du erhältst {{newModifier}}!", - "eggSkipPrompt": "Zur Ei-Zusammenfassung springen?" + "eggSkipPrompt": "Zur Ei-Zusammenfassung springen?", + "battlerTagsHealBlock": "{{pokemonNameWithAffix}} kann nicht geheilt werden, da die Heilung blockiert wird!", + "battlerTagsHealBlockOnRemove": "{{pokemonNameWithAffix}} kann wieder geheilt werden!" } diff --git a/src/locales/en/battle.json b/src/locales/en/battle.json index d04dd3eac1f..2559dafecae 100644 --- a/src/locales/en/battle.json +++ b/src/locales/en/battle.json @@ -105,5 +105,7 @@ "congratulations": "Congratulations!", "beatModeFirstTime": "{{speciesName}} beat {{gameMode}} Mode for the first time!\nYou received {{newModifier}}!", "ppReduced": "It reduced the PP of {{targetName}}'s\n{{moveName}} by {{reduction}}!", - "mysteryEncounterAppeared": "What's this?" + "mysteryEncounterAppeared": "What's this?", + "battlerTagsHealBlock": "{{pokemonNameWithAffix}} can't restore its HP!", + "battlerTagsHealBlockOnRemove": "{{pokemonNameWithAffix}} can restore its HP again!" } \ No newline at end of file diff --git a/src/locales/es/battle.json b/src/locales/es/battle.json index c79315f297b..c5fbf25f3ae 100644 --- a/src/locales/es/battle.json +++ b/src/locales/es/battle.json @@ -85,5 +85,7 @@ "statSeverelyFell_one": "¡El {{stats}} de {{pokemonNameWithAffix}} ha bajado muchísimo!", "statSeverelyFell_other": "¡{{stats}} de\n{{pokemonNameWithAffix}} han bajado muchísimo!", "statWontGoAnyLower_one": "¡El {{stats}} de {{pokemonNameWithAffix}} no puede bajar más!", - "statWontGoAnyLower_other": "¡{{stats}} de\n{{pokemonNameWithAffix}} no pueden bajar más!" + "statWontGoAnyLower_other": "¡{{stats}} de\n{{pokemonNameWithAffix}} no pueden bajar más!", + "battlerTagsHealBlock": "¡{{pokemonNameWithAffix}} no puede restaurar sus PS!", + "battlerTagsHealBlockOnRemove": "¡{{pokemonNameWithAffix}} ya puede recuperar PS!" } diff --git a/src/locales/fr/battle.json b/src/locales/fr/battle.json index 5f5e81350e1..85f206937e7 100644 --- a/src/locales/fr/battle.json +++ b/src/locales/fr/battle.json @@ -99,5 +99,7 @@ "unlockedSomething": "{{unlockedThing}}\na été débloqué.", "congratulations": "Félicitations !", "beatModeFirstTime": "{{speciesName}} a battu le mode {{gameMode}} pour la première fois !\nVous avez reçu {{newModifier}} !", - "eggSkipPrompt": "Aller directement au résumé des Œufs éclos ?" + "eggSkipPrompt": "Aller directement au résumé des Œufs éclos ?", + "battlerTagsHealBlock": "{{pokemonNameWithAffix}} ne peut pas guérir !", + "battlerTagsHealBlockOnRemove": "Le blocage de soins qui affectait\n{{pokemonNameWithAffix}} s’est dissipé !" } diff --git a/src/locales/pt_BR/battle.json b/src/locales/pt_BR/battle.json index 08eeb99e0cd..392f7b2ec38 100644 --- a/src/locales/pt_BR/battle.json +++ b/src/locales/pt_BR/battle.json @@ -96,5 +96,7 @@ "retryBattle": "Você gostaria de tentar novamente desde o início da batalha?", "unlockedSomething": "{{unlockedThing}}\nfoi desbloqueado.", "congratulations": "Parabéns!", - "beatModeFirstTime": "{{speciesName}} venceu o Modo {{gameMode}} pela primeira vez!\nVocê recebeu {{newModifier}}!" + "beatModeFirstTime": "{{speciesName}} venceu o Modo {{gameMode}} pela primeira vez!\nVocê recebeu {{newModifier}}!", + "battlerTagsHealBlock": "{{pokemonNameWithAffix}} não pode restaurar seus PS!", + "battlerTagsHealBlockOnRemove": "{{pokemonNameWithAffix}} pode restaurar seus PS novamente!" } diff --git a/src/phases/pokemon-heal-phase.ts b/src/phases/pokemon-heal-phase.ts index 49db2641e98..813d15ae87e 100644 --- a/src/phases/pokemon-heal-phase.ts +++ b/src/phases/pokemon-heal-phase.ts @@ -10,6 +10,8 @@ import { HealAchv } from "#app/system/achv"; import i18next from "i18next"; import * as Utils from "#app/utils"; import { CommonAnimPhase } from "./common-anim-phase"; +import { BattlerTagType } from "#app/enums/battler-tag-type"; +import { HealBlockTag } from "#app/data/battler-tags"; export class PokemonHealPhase extends CommonAnimPhase { private hpHealed: integer; @@ -50,9 +52,14 @@ export class PokemonHealPhase extends CommonAnimPhase { const hasMessage = !!this.message; const healOrDamage = (!pokemon.isFullHp() || this.hpHealed < 0); + const healBlock = pokemon.getTag(BattlerTagType.HEAL_BLOCK) as HealBlockTag; let lastStatusEffect = StatusEffect.NONE; - if (healOrDamage) { + if (healBlock && this.hpHealed > 0) { + this.scene.queueMessage(healBlock.onActivation(pokemon)); + this.message = null; + super.end(); + } else if (healOrDamage) { const hpRestoreMultiplier = new Utils.IntegerHolder(1); if (!this.revive) { this.scene.applyModifiers(HealingBoosterModifier, this.player, hpRestoreMultiplier); diff --git a/src/phases/select-target-phase.ts b/src/phases/select-target-phase.ts index 716d2737a6c..6f11f984c4b 100644 --- a/src/phases/select-target-phase.ts +++ b/src/phases/select-target-phase.ts @@ -4,6 +4,8 @@ import { Command } from "#app/ui/command-ui-handler"; import { Mode } from "#app/ui/ui"; import { CommandPhase } from "./command-phase"; import { PokemonPhase } from "./pokemon-phase"; +import i18next from "#app/plugins/i18n"; +import { allMoves } from "#app/data/move"; export class SelectTargetPhase extends PokemonPhase { constructor(scene: BattleScene, fieldIndex: integer) { @@ -17,6 +19,14 @@ export class SelectTargetPhase extends PokemonPhase { const move = turnCommand?.move?.move; this.scene.ui.setMode(Mode.TARGET_SELECT, this.fieldIndex, move, (targets: BattlerIndex[]) => { this.scene.ui.setMode(Mode.MESSAGE); + const fieldSide = this.scene.getField(); + const user = fieldSide[this.fieldIndex]; + const moveObject = allMoves[move!]; + if (moveObject && user.isMoveTargetRestricted(moveObject.id, user, fieldSide[targets[0]])) { + const errorMessage = user.getRestrictingTag(move!, user, fieldSide[targets[0]])!.selectionDeniedText(user, moveObject.id); + user.scene.queueMessage(i18next.t(errorMessage, { moveName: moveObject.name }), 0, true); + targets = []; + } if (targets.length < 1) { this.scene.currentBattle.turnCommands[this.fieldIndex] = null; this.scene.unshiftPhase(new CommandPhase(this.scene, this.fieldIndex)); diff --git a/src/test/moves/heal_block.test.ts b/src/test/moves/heal_block.test.ts new file mode 100644 index 00000000000..8549d6211a9 --- /dev/null +++ b/src/test/moves/heal_block.test.ts @@ -0,0 +1,153 @@ +import { BattlerIndex } from "#app/battle"; +import { ArenaTagSide } from "#app/data/arena-tag"; +import { WeatherType } from "#app/data/weather"; +import GameManager from "#app/test/utils/gameManager"; +import { Abilities } from "#enums/abilities"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +const TIMEOUT = 20 * 1000; + +// Bulbapedia Reference: https://bulbapedia.bulbagarden.net/wiki/Heal_Block_(move) +describe("Moves - Heal Block", () => { + 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.ABSORB, Moves.WISH, Moves.SPLASH, Moves.AQUA_RING]) + .enemyMoveset(Moves.HEAL_BLOCK) + .ability(Abilities.NO_GUARD) + .enemyAbility(Abilities.BALL_FETCH) + .enemySpecies(Species.BLISSEY) + .disableCrits(); + }); + + it("shouldn't stop damage from HP-drain attacks, just HP restoration", async() => { + + await game.classicMode.startBattle([Species.CHARIZARD]); + + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + + player.damageAndUpdate(enemy.getMaxHp() - 1); + + game.move.select(Moves.ABSORB); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(player.hp).toBe(1); + expect(enemy.hp).toBeLessThan(enemy.getMaxHp()); + }, TIMEOUT + ); + + it("shouldn't stop Liquid Ooze from dealing damage", async() => { + game.override.enemyAbility(Abilities.LIQUID_OOZE); + + await game.classicMode.startBattle([Species.CHARIZARD]); + + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.ABSORB); + await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(player.isFullHp()).toBe(false); + expect(enemy.isFullHp()).toBe(false); + }, TIMEOUT); + + it("should stop delayed heals, such as from Wish", async() => { + await game.classicMode.startBattle([Species.CHARIZARD]); + + const player = game.scene.getPlayerPokemon()!; + + player.damageAndUpdate(player.getMaxHp() - 1); + + game.move.select(Moves.WISH); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(game.scene.arena.getTagOnSide(ArenaTagType.WISH, ArenaTagSide.PLAYER)).toBeDefined(); + while (game.scene.arena.getTagOnSide(ArenaTagType.WISH, ArenaTagSide.PLAYER)) { + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("TurnEndPhase"); + } + + expect(player.hp).toBe(1); + }, TIMEOUT); + + it("should prevent Grassy Terrain from restoring HP", async() => { + game.override.enemyAbility(Abilities.GRASSY_SURGE); + + await game.classicMode.startBattle([Species.CHARIZARD]); + + const player = game.scene.getPlayerPokemon()!; + + player.damageAndUpdate(player.getMaxHp() - 1); + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(player.hp).toBe(1); + }, TIMEOUT); + + it("should prevent healing from heal-over-time moves", async() => { + await game.classicMode.startBattle([Species.CHARIZARD]); + + const player = game.scene.getPlayerPokemon()!; + + player.damageAndUpdate(player.getMaxHp() - 1); + + game.move.select(Moves.AQUA_RING); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(player.getTag(BattlerTagType.AQUA_RING)).toBeDefined(); + expect(player.hp).toBe(1); + }, TIMEOUT); + + it("should prevent abilities from restoring HP", async() => { + game.override + .weather(WeatherType.RAIN) + .ability(Abilities.RAIN_DISH); + + await game.classicMode.startBattle([Species.CHARIZARD]); + + const player = game.scene.getPlayerPokemon()!; + + player.damageAndUpdate(player.getMaxHp() - 1); + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(player.hp).toBe(1); + }, TIMEOUT); + + it("should stop healing from items", async() => { + game.override.startingHeldItems([{name: "LEFTOVERS"}]); + + await game.classicMode.startBattle([Species.CHARIZARD]); + + const player = game.scene.getPlayerPokemon()!; + player.damageAndUpdate(player.getMaxHp() - 1); + + game.move.select(Moves.SPLASH); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(player.hp).toBe(1); + }, TIMEOUT); +}); From 50dc438930b5900671dd2adfdd1d58e867fa3aec Mon Sep 17 00:00:00 2001 From: Mumble <171087428+frutescens@users.noreply.github.com> Date: Sat, 21 Sep 2024 13:27:19 -0700 Subject: [PATCH 6/7] [Bug] Preventing Duplicate Species in Trainer Battles (#4368) * no trainer repeats * Update src/field/trainer.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --------- Co-authored-by: frutescens Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/field/trainer.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/field/trainer.ts b/src/field/trainer.ts index 1973d7216a6..b1d0263f604 100644 --- a/src/field/trainer.ts +++ b/src/field/trainer.ts @@ -425,14 +425,32 @@ export default class Trainer extends Phaser.GameObjects.Container { } } - if (retry && (attempt || 0) < 10) { + // Prompts reroll of party member species if species already present in the enemy party + if (this.checkDuplicateSpecies(ret)) { + console.log("Duplicate species detected, prompting reroll..."); + retry = true; + } + + if (retry && (attempt ?? 0) < 10) { console.log("Rerolling party member..."); - ret = this.genNewPartyMemberSpecies(level, strength, (attempt || 0) + 1); + ret = this.genNewPartyMemberSpecies(level, strength, (attempt ?? 0) + 1); } return ret; } + /** + * Checks if the enemy trainer already has the Pokemon species in their party + * @param {PokemonSpecies} species {@linkcode PokemonSpecies} + * @returns `true` if the species is already present in the party + */ + checkDuplicateSpecies(species: PokemonSpecies): boolean { + const currentPartySpecies = this.scene.getEnemyParty().map(p => { + return p.species.speciesId; + }); + return currentPartySpecies.includes(species.speciesId); + } + getPartyMemberMatchupScores(trainerSlot: TrainerSlot = TrainerSlot.NONE, forSwitch: boolean = false): [integer, integer][] { if (trainerSlot && !this.isDouble()) { trainerSlot = TrainerSlot.NONE; From 612dcc5f27e75cab7eefde72735310ef9bebc920 Mon Sep 17 00:00:00 2001 From: "Amani H." <109637146+xsn34kzx@users.noreply.github.com> Date: Sat, 21 Sep 2024 21:40:47 -0400 Subject: [PATCH 7/7] [Balance] Adjust Relevant Abilities to Match Lures (#4231) * [Balance] Adjust Relevant Abilities to Match Lures * Add Relevant Unit Tests --- src/battle-scene.ts | 12 +++-- src/data/ability.ts | 19 ++++++-- src/modifier/modifier.ts | 4 +- src/test/abilities/arena_trap.test.ts | 59 +++++++++++++++++++++++ src/test/abilities/illuminate.test.ts | 59 +++++++++++++++++++++++ src/test/abilities/no_guard.test.ts | 68 +++++++++++++++++++++++++++ 6 files changed, 212 insertions(+), 9 deletions(-) create mode 100644 src/test/abilities/arena_trap.test.ts create mode 100644 src/test/abilities/illuminate.test.ts create mode 100644 src/test/abilities/no_guard.test.ts diff --git a/src/battle-scene.ts b/src/battle-scene.ts index d1fcc00e692..08fdd0531c2 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -1146,6 +1146,13 @@ export default class BattleScene extends SceneBase { } } + getDoubleBattleChance(newWaveIndex: number, playerField: PlayerPokemon[]) { + const doubleChance = new Utils.IntegerHolder(newWaveIndex % 10 === 0 ? 32 : 8); + this.applyModifiers(DoubleBattleChanceBoosterModifier, true, doubleChance); + playerField.forEach(p => applyAbAttrs(DoubleBattleChanceAbAttr, p, null, false, doubleChance)); + return Math.max(doubleChance.value, 1); + } + newBattle(waveIndex?: integer, battleType?: BattleType, trainerData?: TrainerData, double?: boolean, mysteryEncounterType?: MysteryEncounterType): Battle | null { const _startingWave = Overrides.STARTING_WAVE_OVERRIDE || startingWave; const newWaveIndex = waveIndex || ((this.currentBattle?.waveIndex || (_startingWave - 1)) + 1); @@ -1229,10 +1236,7 @@ export default class BattleScene extends SceneBase { if (double === undefined && newWaveIndex > 1) { if (newBattleType === BattleType.WILD && !this.gameMode.isWaveFinal(newWaveIndex)) { - const doubleChance = new Utils.IntegerHolder(newWaveIndex % 10 === 0 ? 32 : 8); - this.applyModifiers(DoubleBattleChanceBoosterModifier, true, doubleChance); - playerField.forEach(p => applyAbAttrs(DoubleBattleChanceAbAttr, p, null, false, doubleChance)); - newDouble = !Utils.randSeedInt(doubleChance.value); + newDouble = !Utils.randSeedInt(this.getDoubleBattleChance(newWaveIndex, playerField)); } else if (newBattleType === BattleType.TRAINER) { newDouble = newTrainer?.variant === TrainerVariant.DOUBLE; } diff --git a/src/data/ability.ts b/src/data/ability.ts index 06eb8725f76..28784c07134 100755 --- a/src/data/ability.ts +++ b/src/data/ability.ts @@ -165,14 +165,27 @@ export class BlockRecoilDamageAttr extends AbAttr { } } +/** + * Attribute for abilities that increase the chance of a double battle + * occurring. + * @see apply + */ export class DoubleBattleChanceAbAttr extends AbAttr { constructor() { super(false); } - apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: Utils.BooleanHolder, args: any[]): boolean { - const doubleChance = (args[0] as Utils.IntegerHolder); - doubleChance.value = Math.max(doubleChance.value / 2, 1); + /** + * Increases the chance of a double battle occurring + * @param args [0] {@linkcode Utils.NumberHolder} for double battle chance + * @returns true if the ability was applied + */ + apply(_pokemon: Pokemon, _passive: boolean, _simulated: boolean, _cancelled: Utils.BooleanHolder, args: any[]): boolean { + const doubleBattleChance = args[0] as Utils.NumberHolder; + // This is divided because the chance is generated as a number from 0 to doubleBattleChance.value using Utils.randSeedInt + // A double battle will initiate if the generated number is 0 + doubleBattleChance.value = doubleBattleChance.value / 4; + return true; } } diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 81a3f4f81cc..3a021439f39 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -413,7 +413,7 @@ export class DoubleBattleChanceBoosterModifier extends LapsingPersistentModifier } /** - * Modifies the chance of a double battle occurring + * Increases the chance of a double battle occurring * @param args [0] {@linkcode Utils.NumberHolder} for double battle chance * @returns true if the modifier was applied */ @@ -421,7 +421,7 @@ export class DoubleBattleChanceBoosterModifier extends LapsingPersistentModifier const doubleBattleChance = args[0] as Utils.NumberHolder; // This is divided because the chance is generated as a number from 0 to doubleBattleChance.value using Utils.randSeedInt // A double battle will initiate if the generated number is 0 - doubleBattleChance.value = Math.ceil(doubleBattleChance.value / 4); + doubleBattleChance.value = doubleBattleChance.value / 4; return true; } diff --git a/src/test/abilities/arena_trap.test.ts b/src/test/abilities/arena_trap.test.ts new file mode 100644 index 00000000000..6b313fcc8db --- /dev/null +++ b/src/test/abilities/arena_trap.test.ts @@ -0,0 +1,59 @@ +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("Abilities - Arena Trap", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + const TIMEOUT = 20 * 1000; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset(Moves.SPLASH) + .ability(Abilities.ARENA_TRAP) + .enemySpecies(Species.RALTS) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.TELEPORT); + }); + + // TODO: Enable test when Issue #935 is addressed + it.todo("should not allow grounded Pokémon to flee", async () => { + game.override.battleType("single"); + + await game.classicMode.startBattle(); + + const enemy = game.scene.getEnemyPokemon(); + + game.move.select(Moves.SPLASH); + + await game.toNextTurn(); + + expect(enemy).toBe(game.scene.getEnemyPokemon()); + }, TIMEOUT); + + it("should guarantee double battle with any one LURE", async () => { + game.override + .startingModifier([ + { name: "LURE" }, + ]) + .startingWave(2); + + await game.classicMode.startBattle(); + + expect(game.scene.getEnemyField().length).toBe(2); + }, TIMEOUT); +}); diff --git a/src/test/abilities/illuminate.test.ts b/src/test/abilities/illuminate.test.ts new file mode 100644 index 00000000000..73e4a49be95 --- /dev/null +++ b/src/test/abilities/illuminate.test.ts @@ -0,0 +1,59 @@ +import { Stat } from "#app/enums/stat"; +import { Abilities } from "#enums/abilities"; +import { Moves } from "#enums/moves"; +import GameManager from "#test/utils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, it, expect } from "vitest"; + +describe("Abilities - Illuminate", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + const TIMEOUT = 20 * 1000; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset(Moves.SPLASH) + .ability(Abilities.ILLUMINATE) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SAND_ATTACK); + }); + + it("should prevent ACC stat stage from being lowered", async () => { + game.override.battleType("single"); + + await game.classicMode.startBattle(); + + const player = game.scene.getPlayerPokemon()!; + + expect(player.getStatStage(Stat.ACC)).toBe(0); + + game.move.select(Moves.SPLASH); + + await game.toNextTurn(); + + expect(player.getStatStage(Stat.ACC)).toBe(0); + }, TIMEOUT); + + it("should guarantee double battle with any one LURE", async () => { + game.override + .startingModifier([ + { name: "LURE" }, + ]) + .startingWave(2); + + await game.classicMode.startBattle(); + + expect(game.scene.getEnemyField().length).toBe(2); + }, TIMEOUT); +}); diff --git a/src/test/abilities/no_guard.test.ts b/src/test/abilities/no_guard.test.ts new file mode 100644 index 00000000000..b793ede7099 --- /dev/null +++ b/src/test/abilities/no_guard.test.ts @@ -0,0 +1,68 @@ +import { BattlerIndex } from "#app/battle"; +import { MoveEffectPhase } from "#app/phases/move-effect-phase"; +import { MoveEndPhase } from "#app/phases/move-end-phase"; +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("Abilities - No Guard", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + const TIMEOUT = 20 * 1000; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + game.override + .moveset(Moves.ZAP_CANNON) + .ability(Abilities.NO_GUARD) + .enemyLevel(200) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + it("should make moves always hit regardless of move accuracy", async () => { + game.override.battleType("single"); + + await game.classicMode.startBattle([ + Species.REGIELEKI + ]); + + game.move.select(Moves.ZAP_CANNON); + + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + + await game.phaseInterceptor.to(MoveEffectPhase, false); + + const moveEffectPhase = game.scene.getCurrentPhase() as MoveEffectPhase; + vi.spyOn(moveEffectPhase, "hitCheck"); + + await game.phaseInterceptor.to(MoveEndPhase); + + expect(moveEffectPhase.hitCheck).toHaveReturnedWith(true); + }, TIMEOUT); + + it("should guarantee double battle with any one LURE", async () => { + game.override + .startingModifier([ + { name: "LURE" }, + ]) + .startingWave(2); + + await game.classicMode.startBattle(); + + expect(game.scene.getEnemyField().length).toBe(2); + }, TIMEOUT); +});