From 42eadcb36cc64bee9b9c5d84446fb3659d8bb19b Mon Sep 17 00:00:00 2001 From: ImperialSympathizer Date: Wed, 7 Aug 2024 22:52:12 -0400 Subject: [PATCH] add unit tests for clowning around --- .../mystery-encounters/encounter_radar.json | 41 -- public/images/mystery-encounters/exclaim.json | 41 -- src/data/battle-anims.ts | 2 - .../encounters/clowning-around-encounter.ts | 154 +++++--- .../utils/encounter-phase-utils.ts | 8 +- src/field/mystery-encounter-intro.ts | 3 - src/loading-scene.ts | 3 + src/overrides.ts | 4 +- src/phases.ts | 2 +- .../clowning-around-encounter.test.ts | 374 ++++++++++++++++++ .../the-strong-stuff-encounter.test.ts | 3 +- src/ui/mystery-encounter-ui-handler.ts | 4 +- 12 files changed, 480 insertions(+), 159 deletions(-) delete mode 100644 public/images/mystery-encounters/encounter_radar.json delete mode 100644 public/images/mystery-encounters/exclaim.json create mode 100644 src/test/mystery-encounter/encounters/clowning-around-encounter.test.ts diff --git a/public/images/mystery-encounters/encounter_radar.json b/public/images/mystery-encounters/encounter_radar.json deleted file mode 100644 index 82f16af59f6..00000000000 --- a/public/images/mystery-encounters/encounter_radar.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "textures": [ - { - "image": "encounter_radar.png", - "format": "RGBA8888", - "size": { - "w": 17, - "h": 16 - }, - "scale": 1, - "frames": [ - { - "filename": "0001.png", - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 15, - "h": 14 - }, - "spriteSourceSize": { - "x": 0, - "y": 0, - "w": 15, - "h": 14 - }, - "frame": { - "x": 1, - "y": 1, - "w": 15, - "h": 14 - } - } - ] - } - ], - "meta": { - "app": "https://www.codeandweb.com/texturepacker", - "version": "3.0", - "smartupdate": "$TexturePacker:SmartUpdate:eb3445f19546ab36edb2909c89b8aa86:c8de156a28ef70ee4ddf70cffe1ba3ba:e7008b81ccf0cb0325145a809afa6165$" - } -} diff --git a/public/images/mystery-encounters/exclaim.json b/public/images/mystery-encounters/exclaim.json deleted file mode 100644 index 31231910097..00000000000 --- a/public/images/mystery-encounters/exclaim.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "textures": [ - { - "image": "exclaim.png", - "format": "RGBA8888", - "size": { - "w": 32, - "h": 32 - }, - "scale": 1, - "frames": [ - { - "filename": "0001.png", - "rotated": false, - "trimmed": false, - "sourceSize": { - "w": 32, - "h": 32 - }, - "spriteSourceSize": { - "x": 0, - "y": 0, - "w": 32, - "h": 32 - }, - "frame": { - "x": 0, - "y": 0, - "w": 32, - "h": 32 - } - } - ] - } - ], - "meta": { - "app": "https://www.codeandweb.com/texturepacker", - "version": "3.0", - "smartupdate": "$TexturePacker:SmartUpdate:895f0a79b89fa0fb44167f4584fd9a22:357b46953b7e17c6b2f43a62d52855d8:cc1ed0e4f90aaa9dcf1b39a0af1283b0$" - } -} diff --git a/src/data/battle-anims.ts b/src/data/battle-anims.ts index 6af8fedae8a..ffc39efedac 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -534,13 +534,11 @@ export function initMoveAnim(scene: BattleScene, move: Moves): Promise { export async function initEncounterAnims(scene: BattleScene, encounterAnim: EncounterAnim | EncounterAnim[]): Promise { const anims = Array.isArray(encounterAnim) ? encounterAnim : [encounterAnim]; const encounterAnimNames = Utils.getEnumKeys(EncounterAnim); - // const encounterAnimIds = Utils.getEnumValues(EncounterAnim); const encounterAnimFetches = []; for (const anim of anims) { if (encounterAnims.has(anim) && !isNullOrUndefined(encounterAnims.get(anim))) { continue; } - // const encounterAnimId = encounterAnimIds[anim]; encounterAnimFetches.push(scene.cachedFetch(`./battle-anims/encounter-${encounterAnimNames[anim].toLowerCase().replace(/\_/g, "-")}.json`) .then(response => response.json()) .then(cas => encounterAnims.set(anim, new AnimConfig(cas)))); diff --git a/src/data/mystery-encounters/encounters/clowning-around-encounter.ts b/src/data/mystery-encounters/encounters/clowning-around-encounter.ts index 7f8f622c41f..c03508a5700 100644 --- a/src/data/mystery-encounters/encounters/clowning-around-encounter.ts +++ b/src/data/mystery-encounters/encounters/clowning-around-encounter.ts @@ -1,7 +1,7 @@ import { EnemyPartyConfig, generateModifierTypeOption, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, loadCustomMovesForEncounter, selectPokemonForOption, setEncounterRewards, transitionMysteryEncounterIntroVisuals, } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; import { trainerConfigs, TrainerPartyCompoundTemplate, TrainerPartyTemplate, } from "#app/data/trainer-config"; import { ModifierTier } from "#app/modifier/modifier-tier"; -import { BerryModifierType, modifierTypes, PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; +import { modifierTypes, PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { PartyMemberStrength } from "#enums/party-member-strength"; import BattleScene from "#app/battle-scene"; @@ -104,16 +104,14 @@ export const ClowningAroundEncounter: IMysteryEncounter = .withOnInit((scene: BattleScene) => { const encounter = scene.currentBattle.mysteryEncounter; - // Clown trainer is pulled from pool of boss trainers (gym leaders) for the biome - // They are given an E4 template team, so will be stronger than usual boss encounter and always have 6 mons const clownTrainerType = TrainerType.HARLEQUIN; + const clownConfig = trainerConfigs[clownTrainerType].copy(); const clownPartyTemplate = new TrainerPartyCompoundTemplate( new TrainerPartyTemplate(1, PartyMemberStrength.STRONG), new TrainerPartyTemplate(1, PartyMemberStrength.STRONGER)); - const clownConfig = trainerConfigs[clownTrainerType].copy(); clownConfig.setPartyTemplates(clownPartyTemplate); clownConfig.setDoubleOnly(); - clownConfig.partyTemplateFunc = null; // Overrides party template func + clownConfig.partyTemplateFunc = null; // Overrides party template func if it exists // Generate random ability for Blacephalon from pool const ability = RANDOM_ABILITY_POOL[randSeedInt(RANDOM_ABILITY_POOL.length)]; @@ -142,28 +140,6 @@ export const ClowningAroundEncounter: IMysteryEncounter = // Load animations/sfx for start of fight moves loadCustomMovesForEncounter(scene, [Moves.ROLE_PLAY, Moves.TAUNT]); - // These have to be defined at runtime so that modifierTypes exist - encounter.misc.RANDOM_ULTRA_POOL = [ - modifierTypes.REVIVER_SEED, - modifierTypes.GOLDEN_PUNCH, - modifierTypes.ATTACK_TYPE_BOOSTER, - modifierTypes.QUICK_CLAW, - modifierTypes.WIDE_LENS, - modifierTypes.WHITE_HERB - ]; - - encounter.misc.RANDOM_ROGUE_POOL = [ - modifierTypes.LEFTOVERS, - modifierTypes.SHELL_BELL, - modifierTypes.SOUL_DEW, - modifierTypes.SOOTHE_BELL, - modifierTypes.SCOPE_LENS, - modifierTypes.BATON, - modifierTypes.FOCUS_BAND, - modifierTypes.KINGS_ROCK, - modifierTypes.GRIP_CLAW - ]; - return true; }) .withTitle(`${namespace}.title`) @@ -187,7 +163,7 @@ export const ClowningAroundEncounter: IMysteryEncounter = // Spawn battle const config: EnemyPartyConfig = encounter.enemyPartyConfigs[0]; - setEncounterRewards(scene, { guaranteedModifierTypeFuncs: [modifierTypes.TM_COMMON, modifierTypes.TM_GREAT, modifierTypes.MEMORY_MUSHROOM], fillRemaining: true }); + setEncounterRewards(scene, { fillRemaining: true }); // TODO: when Magic Room and Wonder Room are implemented, add those to start of battle encounter.startOfBattleEffects.push( @@ -217,7 +193,6 @@ export const ClowningAroundEncounter: IMysteryEncounter = // After the battle, offer the player the opportunity to permanently swap ability const abilityWasSwapped = await handleSwapAbility(scene); if (abilityWasSwapped) { - await scene.ui.setMode(Mode.MESSAGE); await showEncounterText(scene, `${namespace}.option.1.ability_gained`); } @@ -284,44 +259,33 @@ export const ClowningAroundEncounter: IMysteryEncounter = const items = mostHeldItemsPokemon.getHeldItems(); // Shuffles Berries (if they have any) - const berries = items.filter(m => m instanceof BerryModifier); + let numBerries = 0; + items.filter(m => m instanceof BerryModifier) + .forEach(m => { + numBerries += m.stackCount; + scene.removeModifier(m); + }); - berries.forEach(berry => { - const stackCount = berry.stackCount; - scene.removeModifier(berry); - const newBerry = generateModifierTypeOption(scene, modifierTypes.BERRY, [randSeedInt(Object.keys(BerryType).filter(s => !isNaN(Number(s))).length) as BerryType]).type as BerryModifierType; - for (let i = 0; i < stackCount; i++) { - applyModifierTypeToPlayerPokemon(scene, mostHeldItemsPokemon, newBerry); - } - }); + generateItemsOfTier(scene, mostHeldItemsPokemon, numBerries, "Berries"); // Shuffle Transferable held items in the same tier (only shuffles Ultra and Rogue atm) - const transferableItems = items.filter(m => m.isTransferrable && !(m instanceof BerryModifier)); - - transferableItems.forEach(transferableItem => { - const stackCount = transferableItem.stackCount; - transferableItem.type.withTierFromPool(); - - // Lucky Eggs and other items that do not appear in item pools are treated as Ultra rarity - const tier = transferableItem.type.tier ?? ModifierTier.ULTRA; - - if (tier === ModifierTier.ULTRA) { - scene.removeModifier(transferableItem); - for (let i = 0; i < stackCount; i++) { - const newItemType = encounter.misc.RANDOM_ULTRA_POOL[randSeedInt(encounter.misc.RANDOM_ULTRA_POOL.length)]; - const newMod = generateModifierTypeOption(scene, newItemType).type as PokemonHeldItemModifierType; - applyModifierTypeToPlayerPokemon(scene, mostHeldItemsPokemon, newMod); + let numUltra = 0; + let numRogue = 0; + items.filter(m => m.isTransferrable && !(m instanceof BerryModifier)) + .forEach(m => { + const type = m.type.withTierFromPool(); + const tier = type.tier ?? ModifierTier.ULTRA; + if (type.id === "LUCKY_EGG" || tier === ModifierTier.ULTRA) { + numUltra += m.stackCount; + scene.removeModifier(m); + } else if (type.id === "GOLDEN_EGG" || tier === ModifierTier.ROGUE) { + numRogue += m.stackCount; + scene.removeModifier(m); } - } else if (tier === ModifierTier.ROGUE) { - scene.removeModifier(transferableItem); - for (let i = 0; i < stackCount; i++) { - const newItemType = encounter.misc.RANDOM_ROGUE_POOL[randSeedInt(encounter.misc.RANDOM_ROGUE_POOL.length)]; - const newMod = generateModifierTypeOption(scene, newItemType).type as PokemonHeldItemModifierType; - applyModifierTypeToPlayerPokemon(scene, mostHeldItemsPokemon, newMod); - } - } - }); + }); + generateItemsOfTier(scene, mostHeldItemsPokemon, numUltra, ModifierTier.ULTRA); + generateItemsOfTier(scene, mostHeldItemsPokemon, numRogue, ModifierTier.ROGUE); }) .withOptionPhase(async (scene: BattleScene) => { leaveEncounterWithoutBattle(scene, true); @@ -456,7 +420,7 @@ function onYesAbilitySwap(scene: BattleScene, resolve) { } pokemon.mysteryEncounterData.ability = scene.currentBattle.mysteryEncounter.misc.ability; scene.currentBattle.mysteryEncounter.setDialogueToken("chosenPokemon", pokemon.getNameToRender()); - resolve(true); + scene.ui.setMode(Mode.MESSAGE).then(() => resolve(true)); }; const onPokemonNotSelected = () => { @@ -467,3 +431,67 @@ function onYesAbilitySwap(scene: BattleScene, resolve) { selectPokemonForOption(scene, onPokemonSelected, onPokemonNotSelected); } + +function generateItemsOfTier(scene: BattleScene, pokemon: PlayerPokemon, numItems: integer, tier: ModifierTier | "Berries") { + // These pools have to be defined at runtime so that modifierTypes exist + // Pools have instances of the modifier type equal to the max stacks that modifier can be applied to any one pokemon + // This is to prevent "over-generating" a random item of a certain type during item swaps + const ultraPool = [ + [modifierTypes.REVIVER_SEED, 1], + [modifierTypes.GOLDEN_PUNCH, 5], + [modifierTypes.ATTACK_TYPE_BOOSTER, 99], + [modifierTypes.QUICK_CLAW, 3], + [modifierTypes.WIDE_LENS, 3], + [modifierTypes.WHITE_HERB, 2] + ]; + + const roguePool = [ + [modifierTypes.LEFTOVERS, 4], + [modifierTypes.SHELL_BELL, 4], + [modifierTypes.SOUL_DEW, 10], + [modifierTypes.SOOTHE_BELL, 3], + [modifierTypes.SCOPE_LENS, 5], + [modifierTypes.BATON, 1], + [modifierTypes.FOCUS_BAND, 5], + [modifierTypes.KINGS_ROCK, 3], + [modifierTypes.GRIP_CLAW, 5] + ]; + + const berryPool = [ + [BerryType.APICOT, 3], + [BerryType.ENIGMA, 2], + [BerryType.GANLON, 3], + [BerryType.LANSAT, 3], + [BerryType.LEPPA, 2], + [BerryType.LIECHI, 3], + [BerryType.LUM, 2], + [BerryType.PETAYA, 3], + [BerryType.SALAC, 2], + [BerryType.SITRUS, 2], + [BerryType.STARF, 3] + ]; + + let pool: any[]; + if (tier === "Berries") { + pool = berryPool; + } else { + pool = tier === ModifierTier.ULTRA ? ultraPool : roguePool; + } + + for (let i = 0; i < numItems; i++) { + const randIndex = randSeedInt(pool.length); + const newItemType = pool[randIndex]; + let newMod; + if (tier === "Berries") { + newMod = generateModifierTypeOption(scene, modifierTypes.BERRY, [newItemType[0]]).type as PokemonHeldItemModifierType; + } else { + newMod = generateModifierTypeOption(scene, newItemType[0]).type as PokemonHeldItemModifierType; + } + applyModifierTypeToPlayerPokemon(scene, pokemon, newMod); + // Decrement max stacks and remove from pool if at max + newItemType[1]--; + if (newItemType[1] <= 0) { + pool.splice(randIndex, 1); + } + } +} diff --git a/src/data/mystery-encounters/utils/encounter-phase-utils.ts b/src/data/mystery-encounters/utils/encounter-phase-utils.ts index 675e864f976..05ddf07d1e8 100644 --- a/src/data/mystery-encounters/utils/encounter-phase-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-phase-utils.ts @@ -38,7 +38,7 @@ import { MysteryEncounterPokemonData } from "#app/data/mystery-encounters/myster * @param scene */ export function doTrainerExclamation(scene: BattleScene) { - const exclamationSprite = scene.addFieldSprite(0, 0, "exclaim"); + const exclamationSprite = scene.add.sprite(0, 0, "exclaim"); exclamationSprite.setName("exclamation"); scene.field.add(exclamationSprite); scene.field.moveTo(exclamationSprite, scene.field.getAll().length - 1); @@ -386,10 +386,12 @@ export function generateModifierTypeOption(scene: BattleScene, modifier: () => M */ export function selectPokemonForOption(scene: BattleScene, onPokemonSelected: (pokemon: PlayerPokemon) => void | OptionSelectItem[], onPokemonNotSelected?: () => void, selectablePokemonFilter?: (pokemon: PlayerPokemon) => string): Promise { return new Promise(resolve => { + const modeToSetOnExit = scene.ui.getMode(); + // Open party screen to choose pokemon to train scene.ui.setMode(Mode.PARTY, PartyUiMode.SELECT, -1, (slotIndex: integer, option: PartyOption) => { if (slotIndex < scene.getParty().length) { - scene.ui.setMode(Mode.MYSTERY_ENCOUNTER).then(() => { + scene.ui.setMode(modeToSetOnExit).then(() => { const pokemon = scene.getParty()[slotIndex]; const secondaryOptions = onPokemonSelected(pokemon); if (!secondaryOptions) { @@ -443,7 +445,7 @@ export function selectPokemonForOption(scene: BattleScene, onPokemonSelected: (p }); }); } else { - scene.ui.setMode(Mode.MYSTERY_ENCOUNTER).then(() => { + scene.ui.setMode(modeToSetOnExit).then(() => { if (onPokemonNotSelected) { onPokemonNotSelected(); } diff --git a/src/field/mystery-encounter-intro.ts b/src/field/mystery-encounter-intro.ts index 7055d29d45c..0f13f486eb2 100644 --- a/src/field/mystery-encounter-intro.ts +++ b/src/field/mystery-encounter-intro.ts @@ -190,9 +190,6 @@ export default class MysteryEncounterIntroVisuals extends Phaser.GameObjects.Con } }); - // Load dex progress icon - this.scene.loadAtlas("encounter_radar", "mystery-encounters"); - this.scene.load.once(Phaser.Loader.Events.COMPLETE, () => { this.spriteConfigs.every((config) => { if (config.isItem) { diff --git a/src/loading-scene.ts b/src/loading-scene.ts index 3d62248f693..54acc2dd0d3 100644 --- a/src/loading-scene.ts +++ b/src/loading-scene.ts @@ -275,6 +275,9 @@ export class LoadingScene extends SceneBase { } } + // Load Mystery Encounter dex progress icon + this.loadImage("encounter_radar", "mystery-encounters"); + this.loadAtlas("dualshock", "inputs"); this.loadAtlas("xbox", "inputs"); this.loadAtlas("keyboard", "inputs"); diff --git a/src/overrides.ts b/src/overrides.ts index 1196797fac0..6632dfeab6f 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -127,9 +127,9 @@ class DefaultOverrides { // ------------------------- // 1 to 256, set to null to ignore - readonly MYSTERY_ENCOUNTER_RATE_OVERRIDE: number = null; + readonly MYSTERY_ENCOUNTER_RATE_OVERRIDE: number = 256; readonly MYSTERY_ENCOUNTER_TIER_OVERRIDE: MysteryEncounterTier = null; - readonly MYSTERY_ENCOUNTER_OVERRIDE: MysteryEncounterType = null; + readonly MYSTERY_ENCOUNTER_OVERRIDE: MysteryEncounterType = MysteryEncounterType.TRAINING_SESSION; // ------------------------- // MODIFIER / ITEM OVERRIDES diff --git a/src/phases.ts b/src/phases.ts index 56206a97f1a..c6b238c5d79 100644 --- a/src/phases.ts +++ b/src/phases.ts @@ -915,7 +915,7 @@ export class EncounterPhase extends BattlePhase { // Load Mystery Encounter Exclamation bubble and sfx loadEnemyAssets.push(new Promise(resolve => { this.scene.loadSe("GEN8- Exclaim", "battle_anims", "GEN8- Exclaim.wav"); - this.scene.loadAtlas("exclaim", "mystery-encounters"); + this.scene.loadImage("exclaim", "mystery-encounters"); this.scene.load.once(Phaser.Loader.Events.COMPLETE, () => resolve()); if (!this.scene.load.isLoading()) { this.scene.load.start(); diff --git a/src/test/mystery-encounter/encounters/clowning-around-encounter.test.ts b/src/test/mystery-encounter/encounters/clowning-around-encounter.test.ts new file mode 100644 index 00000000000..1f2c02cf03e --- /dev/null +++ b/src/test/mystery-encounter/encounters/clowning-around-encounter.test.ts @@ -0,0 +1,374 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#app/enums/biome"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import * as BattleAnims from "#app/data/battle-anims"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { generateModifierTypeOption } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { runMysteryEncounterToEnd, skipBattleRunMysteryEncounterRewardsPhase } from "#test/mystery-encounter/encounterTestUtils"; +import { CommandPhase, MovePhase, NewBattlePhase, SelectModifierPhase } from "#app/phases"; +import { Moves } from "#enums/moves"; +import BattleScene from "#app/battle-scene"; +import Pokemon, { PokemonMove } from "#app/field/pokemon"; +import { Mode } from "#app/ui/ui"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { ModifierTier } from "#app/modifier/modifier-tier"; +import { ClowningAroundEncounter } from "#app/data/mystery-encounters/encounters/clowning-around-encounter"; +import { TrainerType } from "#enums/trainer-type"; +import { Abilities } from "#enums/abilities"; +import { PostMysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { Button } from "#enums/buttons"; +import PartyUiHandler from "#app/ui/party-ui-handler"; +import OptionSelectUiHandler from "#app/ui/settings/option-select-ui-handler"; +import { modifierTypes, PokemonHeldItemModifierType } from "#app/modifier/modifier-type"; +import { BerryType } from "#enums/berry-type"; +import { PokemonHeldItemModifier } from "#app/modifier/modifier"; +import { Type } from "#app/data/type"; + +const namespace = "mysteryEncounter:clowningAround"; +const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; +const defaultBiome = Biome.CAVE; +const defaultWave = 45; + +describe("Clowning Around - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + let scene: BattleScene; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + scene = game.scene; + game.override.mysteryEncounterChance(100); + game.override.mysteryEncounterTier(MysteryEncounterTier.COMMON); + game.override.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.CAVE, [MysteryEncounterType.CLOWNING_AROUND]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.CLOWNING_AROUND, defaultParty); + + expect(ClowningAroundEncounter.encounterType).toBe(MysteryEncounterType.CLOWNING_AROUND); + expect(ClowningAroundEncounter.encounterTier).toBe(MysteryEncounterTier.ULTRA); + expect(ClowningAroundEncounter.dialogue).toBeDefined(); + expect(ClowningAroundEncounter.dialogue.intro).toStrictEqual([ + { text: `${namespace}.intro` }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.intro_dialogue`, + }, + ]); + expect(ClowningAroundEncounter.dialogue.encounterOptionsDialogue.title).toBe(`${namespace}.title`); + expect(ClowningAroundEncounter.dialogue.encounterOptionsDialogue.description).toBe(`${namespace}.description`); + expect(ClowningAroundEncounter.dialogue.encounterOptionsDialogue.query).toBe(`${namespace}.query`); + expect(ClowningAroundEncounter.options.length).toBe(3); + }); + + it("should not run below wave 80", async () => { + game.override.startingWave(79); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.CLOWNING_AROUND); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(181); + + await game.runToMysteryEncounter(); + + expect(scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = ClowningAroundEncounter; + const moveInitSpy = vi.spyOn(BattleAnims, "initMoveAnim"); + const moveLoadSpy = vi.spyOn(BattleAnims, "loadMoveAnimAssets"); + + const { onInit } = ClowningAroundEncounter; + + expect(ClowningAroundEncounter.onInit).toBeDefined(); + + ClowningAroundEncounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit(scene); + const config = ClowningAroundEncounter.enemyPartyConfigs[0]; + + expect(config.doubleBattle).toBe(true); + expect(config.trainerConfig.trainerType).toBe(TrainerType.HARLEQUIN); + expect(config.pokemonConfigs[0]).toEqual({ + species: getPokemonSpecies(Species.MR_MIME), + isBoss: true, + moveSet: [Moves.TEETER_DANCE, Moves.ALLY_SWITCH, Moves.DAZZLING_GLEAM, Moves.PSYCHIC] + }); + expect(config.pokemonConfigs[1]).toEqual({ + species: getPokemonSpecies(Species.BLACEPHALON), + ability: expect.any(Number), + mysteryEncounterData: expect.anything(), + isBoss: true, + moveSet: [Moves.TRICK, Moves.HYPNOSIS, Moves.SHADOW_BALL, Moves.MIND_BLOWN] + }); + expect(config.pokemonConfigs[1].mysteryEncounterData.types.length).toBe(2); + expect([ + Abilities.STURDY, + Abilities.PICKUP, + Abilities.INTIMIDATE, + Abilities.GUTS, + Abilities.DROUGHT, + Abilities.DRIZZLE, + Abilities.SNOW_WARNING, + Abilities.SAND_STREAM, + Abilities.ELECTRIC_SURGE, + Abilities.PSYCHIC_SURGE, + Abilities.GRASSY_SURGE, + Abilities.MISTY_SURGE, + Abilities.MAGICIAN, + Abilities.SHEER_FORCE, + Abilities.PRANKSTER + ]).toContain(config.pokemonConfigs[1].ability); + expect(ClowningAroundEncounter.misc.ability).toBe(config.pokemonConfigs[1].ability); + await vi.waitFor(() => expect(moveInitSpy).toHaveBeenCalled()); + await vi.waitFor(() => expect(moveLoadSpy).toHaveBeenCalled()); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Battle the Clown", () => { + it("should have the correct properties", () => { + const option = ClowningAroundEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.1.label`, + buttonTooltip: `${namespace}.option.1.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.1.selected`, + }, + ], + }); + }); + + it("should start double battle against the clown", async () => { + const phaseSpy = vi.spyOn(scene, "pushPhase"); + + await game.runToMysteryEncounter(MysteryEncounterType.CLOWNING_AROUND, defaultParty); + await runMysteryEncounterToEnd(game, 1, null, true); + + const enemyField = scene.getEnemyField(); + expect(scene.getCurrentPhase().constructor.name).toBe(CommandPhase.name); + expect(enemyField.length).toBe(2); + expect(enemyField[0].species.speciesId).toBe(Species.MR_MIME); + expect(enemyField[0].moveset).toEqual([new PokemonMove(Moves.TEETER_DANCE), new PokemonMove(Moves.ALLY_SWITCH), new PokemonMove(Moves.DAZZLING_GLEAM), new PokemonMove(Moves.PSYCHIC)]); + expect(enemyField[1].species.speciesId).toBe(Species.BLACEPHALON); + expect(enemyField[1].moveset).toEqual([new PokemonMove(Moves.TRICK), new PokemonMove(Moves.HYPNOSIS), new PokemonMove(Moves.SHADOW_BALL), new PokemonMove(Moves.MIND_BLOWN)]); + + // Should have used moves pre-battle + const movePhases = phaseSpy.mock.calls.filter(p => p[0] instanceof MovePhase).map(p => p[0]); + expect(movePhases.length).toBe(3); + expect(movePhases.filter(p => (p as MovePhase).move.moveId === Moves.ROLE_PLAY).length).toBe(1); + expect(movePhases.filter(p => (p as MovePhase).move.moveId === Moves.TAUNT).length).toBe(2); + }); + + it("should let the player gain the ability after battle completion", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.CLOWNING_AROUND, defaultParty); + await runMysteryEncounterToEnd(game, 1, null, true); + await skipBattleRunMysteryEncounterRewardsPhase(game); + await game.phaseInterceptor.to(SelectModifierPhase, false); + expect(scene.getCurrentPhase().constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + const abilityToTrain = scene.currentBattle.mysteryEncounter.misc.ability; + + game.onNextPrompt("PostMysteryEncounterPhase", Mode.MESSAGE, () => { + game.scene.ui.getHandler().processInput(Button.ACTION); + }); + + // Run to ability train option selection + const optionSelectUiHandler = game.scene.ui.handlers[Mode.OPTION_SELECT] as OptionSelectUiHandler; + vi.spyOn(optionSelectUiHandler, "show"); + const partyUiHandler = game.scene.ui.handlers[Mode.PARTY] as PartyUiHandler; + vi.spyOn(partyUiHandler, "show"); + game.endPhase(); + await game.phaseInterceptor.to(PostMysteryEncounterPhase); + expect(scene.getCurrentPhase().constructor.name).toBe(PostMysteryEncounterPhase.name); + + // Wait for Yes/No confirmation to appear + await vi.waitFor(() => expect(optionSelectUiHandler.show).toHaveBeenCalled()); + // Select "Yes" on train ability + optionSelectUiHandler.processInput(Button.ACTION); + // Select first pokemon in party to train + await vi.waitFor(() => expect(partyUiHandler.show).toHaveBeenCalled()); + partyUiHandler.processInput(Button.ACTION); + // Click "Select" on Pokemon + partyUiHandler.processInput(Button.ACTION); + // Stop next battle before it runs + await game.phaseInterceptor.to(NewBattlePhase, false); + + const leadPokemon = scene.getParty()[0]; + expect(leadPokemon.mysteryEncounterData.ability).toBe(abilityToTrain); + }); + }); + + describe("Option 2 - Remain Unprovoked", () => { + it("should have the correct properties", () => { + const option = ClowningAroundEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.2.label`, + buttonTooltip: `${namespace}.option.2.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.2.selected`, + }, + { + text: `${namespace}.option.2.selected_2`, + }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.2.selected_3`, + }, + ], + }); + }); + + it("should randomize held items of the Pokemon with the most items, and not the held items of other pokemon", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.CLOWNING_AROUND, defaultParty); + + // 2 Sitrus Berries on lead + scene.modifiers = []; + let itemType = generateModifierTypeOption(scene, modifierTypes.BERRY, [BerryType.SITRUS]).type as PokemonHeldItemModifierType; + await addItemToPokemon(scene, scene.getParty()[0], 2, itemType); + // 2 Ganlon Berries on lead + itemType = generateModifierTypeOption(scene, modifierTypes.BERRY, [BerryType.GANLON]).type as PokemonHeldItemModifierType; + await addItemToPokemon(scene, scene.getParty()[0], 2, itemType); + // 5 Golden Punch on lead (ultra) + itemType = generateModifierTypeOption(scene, modifierTypes.GOLDEN_PUNCH).type as PokemonHeldItemModifierType; + await addItemToPokemon(scene, scene.getParty()[0], 5, itemType); + // 5 Lucky Egg on lead (ultra) + itemType = generateModifierTypeOption(scene, modifierTypes.LUCKY_EGG).type as PokemonHeldItemModifierType; + await addItemToPokemon(scene, scene.getParty()[0], 5, itemType); + // 5 Soul Dew on lead (rogue) + itemType = generateModifierTypeOption(scene, modifierTypes.SOUL_DEW).type as PokemonHeldItemModifierType; + await addItemToPokemon(scene, scene.getParty()[0], 5, itemType); + // 2 Golden Egg on lead (rogue) + itemType = generateModifierTypeOption(scene, modifierTypes.GOLDEN_EGG).type as PokemonHeldItemModifierType; + await addItemToPokemon(scene, scene.getParty()[0], 2, itemType); + + // 5 Soul Dew on second party pokemon (these should not change) + itemType = generateModifierTypeOption(scene, modifierTypes.SOUL_DEW).type as PokemonHeldItemModifierType; + await addItemToPokemon(scene, scene.getParty()[1], 5, itemType); + + await runMysteryEncounterToEnd(game, 2); + + const leadItemsAfter = scene.getParty()[0].getHeldItems(); + const ultraCountAfter = leadItemsAfter + .filter(m => m.type.tier === ModifierTier.ULTRA) + .reduce((a, b) => a + b.stackCount, 0); + const rogueCountAfter = leadItemsAfter + .filter(m => m.type.tier === ModifierTier.ROGUE) + .reduce((a, b) => a + b.stackCount, 0); + expect(ultraCountAfter).toBe(10); + expect(rogueCountAfter).toBe(7); + + const secondItemsAfter = scene.getParty()[1].getHeldItems(); + expect(secondItemsAfter.length).toBe(1); + expect(secondItemsAfter[0].type.id).toBe("SOUL_DEW"); + expect(secondItemsAfter[0].stackCount).toBe(5); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.CLOWNING_AROUND, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - Return the Insults", () => { + it("should have the correct properties", () => { + const option = ClowningAroundEncounter.options[2]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}.option.3.label`, + buttonTooltip: `${namespace}.option.3.tooltip`, + selected: [ + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.3.selected`, + }, + { + text: `${namespace}.option.3.selected_2`, + }, + { + speaker: `${namespace}.speaker`, + text: `${namespace}.option.3.selected_3`, + }, + ], + }); + }); + + it("should randomize the pokemon types of the party", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.CLOWNING_AROUND, defaultParty); + + // Same type moves on lead + scene.getParty()[0].moveset = [new PokemonMove(Moves.ICE_BEAM), new PokemonMove(Moves.SURF)]; + // Different type moves on second + scene.getParty()[1].moveset = [new PokemonMove(Moves.GRASS_KNOT), new PokemonMove(Moves.ELECTRO_BALL)]; + // No moves on third + scene.getParty()[2].moveset = []; + await runMysteryEncounterToEnd(game, 3); + + const leadTypesAfter = scene.getParty()[0].mysteryEncounterData.types; + const secondaryTypesAfter = scene.getParty()[1].mysteryEncounterData.types; + const thirdTypesAfter = scene.getParty()[2].mysteryEncounterData.types; + + expect(leadTypesAfter.length).toBe(2); + expect(leadTypesAfter).not.toBe([Type.ICE, Type.WATER]); + expect(secondaryTypesAfter.length).toBe(2); + expect(secondaryTypesAfter.includes(Type.GRASS)).toBeTruthy(); + expect(secondaryTypesAfter.includes(Type.ELECTRIC)).toBeTruthy(); + expect(thirdTypesAfter.length).toBe(1); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.CLOWNING_AROUND, defaultParty); + await runMysteryEncounterToEnd(game, 3); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); + +async function addItemToPokemon(scene: BattleScene, pokemon: Pokemon, stackCount: integer, itemType: PokemonHeldItemModifierType) { + const itemMod = itemType.newModifier(pokemon) as PokemonHeldItemModifier; + itemMod.stackCount = stackCount; + await scene.addModifier(itemMod, true, false, false, true); + await scene.updateModifiers(true); +} diff --git a/src/test/mystery-encounter/encounters/the-strong-stuff-encounter.test.ts b/src/test/mystery-encounter/encounters/the-strong-stuff-encounter.test.ts index 105fbb3fd6b..db89e15b33f 100644 --- a/src/test/mystery-encounter/encounters/the-strong-stuff-encounter.test.ts +++ b/src/test/mystery-encounter/encounters/the-strong-stuff-encounter.test.ts @@ -23,6 +23,7 @@ import { PokemonBaseStatTotalModifier } from "#app/modifier/modifier"; import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import { MysteryEncounterPokemonData } from "#app/data/mystery-encounters/mystery-encounter-pokemon-data"; const namespace = "mysteryEncounter:theStrongStuff"; const defaultParty = [Species.LAPRAS, Species.GENGAR, Species.ABRA]; @@ -118,7 +119,7 @@ describe("The Strong Stuff - Mystery Encounter", () => { species: getPokemonSpecies(Species.SHUCKLE), isBoss: true, bossSegments: 5, - spriteScale: 1.5, + mysteryEncounterData: new MysteryEncounterPokemonData(1.5), nature: Nature.BOLD, moveSet: [Moves.INFESTATION, Moves.SALT_CURE, Moves.GASTRO_ACID, Moves.HEAL_ORDER], modifierTypes: expect.any(Array), diff --git a/src/ui/mystery-encounter-ui-handler.ts b/src/ui/mystery-encounter-ui-handler.ts index 3d89afcd784..04642bec37b 100644 --- a/src/ui/mystery-encounter-ui-handler.ts +++ b/src/ui/mystery-encounter-ui-handler.ts @@ -81,8 +81,8 @@ export default class MysteryEncounterUiHandler extends UiHandler { this.rarityBall.setScale(0.75); this.descriptionContainer.add(this.rarityBall); - const dexProgressIndicator = this.scene.add.sprite(12, 9, "encounter_radar"); - dexProgressIndicator.setScale(0.85); + const dexProgressIndicator = this.scene.add.sprite(12, 10, "encounter_radar"); + dexProgressIndicator.setScale(0.80); this.dexProgressContainer.add(dexProgressIndicator); this.dexProgressContainer.setInteractive(new Phaser.Geom.Rectangle(0, 0, 24, 28), Phaser.Geom.Rectangle.Contains); this.dexProgressContainer.on("pointerover", () => {