From 821e380e571b76771cde22571449a1286dc9508e Mon Sep 17 00:00:00 2001 From: Diogo Diniz Date: Thu, 5 Jun 2025 21:33:16 +0100 Subject: [PATCH] Fix #5487: Implements Sky Battle Mystery Encounter Adds a new Mystery Encounter, a Sky Battle, which is inspired by the same type of battle in the mainline games. This battle is triggered if the player has enough flying pokemon. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Marques --- public/images/trainer/sky_trainer_f.json | 41 ++ public/images/trainer/sky_trainer_f.png | Bin 0 -> 1070 bytes public/images/trainer/sky_trainer_m.json | 41 ++ public/images/trainer/sky_trainer_m.png | Bin 0 -> 1006 bytes .../encounters/sky-battle-encounter.ts | 482 ++++++++++++++++++ .../mystery-encounter-requirements.ts | 48 ++ .../mystery-encounters/mystery-encounters.ts | 44 ++ src/data/trainers/trainer-config.ts | 5 + src/enums/mystery-encounter-type.ts | 3 +- src/enums/trainer-type.ts | 1 + src/plugins/i18n.ts | 20 +- .../encounters/sky-battle-encounter.test.ts | 408 +++++++++++++++ 12 files changed, 1091 insertions(+), 2 deletions(-) create mode 100644 public/images/trainer/sky_trainer_f.json create mode 100644 public/images/trainer/sky_trainer_f.png create mode 100644 public/images/trainer/sky_trainer_m.json create mode 100644 public/images/trainer/sky_trainer_m.png create mode 100644 src/data/mystery-encounters/encounters/sky-battle-encounter.ts create mode 100644 test/mystery-encounter/encounters/sky-battle-encounter.test.ts diff --git a/public/images/trainer/sky_trainer_f.json b/public/images/trainer/sky_trainer_f.json new file mode 100644 index 00000000000..4080abef623 --- /dev/null +++ b/public/images/trainer/sky_trainer_f.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "sky_trainer_f.png", + "format": "RGBA8888", + "size": { + "w": 56, + "h": 67 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 56, + "h": 67 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 56, + "h": 67 + }, + "frame": { + "x": 0, + "y": 0, + "w": 56, + "h": 67 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:d051332055512f245ae68e912a28cd81:4926ba3fd9fe432bb535d13f4df1df4e:621aca0914900a3b9e235ebf0431ce2b$" + } +} diff --git a/public/images/trainer/sky_trainer_f.png b/public/images/trainer/sky_trainer_f.png new file mode 100644 index 0000000000000000000000000000000000000000..ded6662663000105555fb8b955f9791acf2461e9 GIT binary patch literal 1070 zcmeAS@N?(olHy`uVBq!ia0vp^7C`LG!3-q-?KRT_QVPi)LB0$ORcZ_j4J`}|zkosw zFBlj~4Hy_+B``2p&0t^+^7k6}13By^p1!W^FIlBTM0xbTSAJq(VA|*D;uzv_eD1a0Nplo<7!sOT z0wQnkuKi!0E5zb>VE(eXvuBs>_vJBMbw#1Uc`x5tL*Hkru3N5I9T(BJ&+tFDbjQS7 zxAztEtzV+L!av}(&hZJcGbbyY`#djv_C}Sb-W(mDCoS3M_i}a6St*XTh)>!Q3TF~y zC-MuQ<%sf~I!&nexM@w;#0<9yQ`g_>dd()^WnUle^6H#{vQqi;ANdPncrLC_*s^;T z!=e%vP0cfp>^3wuddBn5{Zco5#~OPc&G|3+`(#gk{r=ru=F6O|_vAztO*!AFc=DXX z@yz`%lP(=t(4&4VJ5E*p+nsGM-+#AYF=$a)c}+{_1fw^zuI2x~POQA%`ovmzzda?|8mXH4c1o zf0v)$q?9!!hgY^PUVCoBJD=yuCON0%4=-=zQ9o-d&*sOg6PxicE!U@_ZR%`i-j{rx z0?UopygKq+;FCM^?wu<|A2ZBR{7}bNJ+-&{LaD-qzoy1d7sO9vyTrh;X7*vfl&;Cb z60xj{tuuCuh4@AAIh3uu_fIY>;pGpZ>G$R>-EDD@w>bV#klX!A{R`+NfgK9-_haUG|LoJ_gT=5QHmlrru;7x2vZ!P1up#q*PQHJy)Ty}v_fSNR21kK2a- zUr)Xsc7-YS>6yE`mph#OJw?3EPknNoy5u|mt}`h~^3FOpSV|V(+xEOcqi5dpJuC86 z)jBjz{^0)c^zfbQU4O*nuig`u*|TlV5}UXU1w8MKS9v6bUz)Aew6lG}bS29Zn)e+t z(|4Ut_CEke{dd zc58_~Fl{rambgZgq$HN4S|t~y0x1R~10z#i15;fivk)T#DPsvQH#I2!eRunL=FlfMSD9OxCEiOsSEx@hkYJasZ$XTAQelF{r5}E)JcgIBl literal 0 HcmV?d00001 diff --git a/public/images/trainer/sky_trainer_m.json b/public/images/trainer/sky_trainer_m.json new file mode 100644 index 00000000000..9cf750481b2 --- /dev/null +++ b/public/images/trainer/sky_trainer_m.json @@ -0,0 +1,41 @@ +{ + "textures": [ + { + "image": "sky_trainer_m.png", + "format": "RGBA8888", + "size": { + "w": 48, + "h": 79 + }, + "scale": 1, + "frames": [ + { + "filename": "0001.png", + "rotated": false, + "trimmed": true, + "sourceSize": { + "w": 48, + "h": 79 + }, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 48, + "h": 79 + }, + "frame": { + "x": 0, + "y": 0, + "w": 48, + "h": 79 + } + } + ] + } + ], + "meta": { + "app": "https://www.codeandweb.com/texturepacker", + "version": "3.0", + "smartupdate": "$TexturePacker:SmartUpdate:d051332055512f245ae68e912a28cd81:4926ba3fd9fe432bb535d13f4df1df4e:621aca0914900a3b9e235ebf0431ce2b$" + } +} diff --git a/public/images/trainer/sky_trainer_m.png b/public/images/trainer/sky_trainer_m.png new file mode 100644 index 0000000000000000000000000000000000000000..0a817932b05e7eb45b5c9aa41aefe940825bd96e GIT binary patch literal 1006 zcmeAS@N?(olHy`uVBq!ia0vp^20-l3!3-p4t=P5@NGT+H1o<*BRH-pAG_)`<`~nI! zykKA`1uA@%z`$TNgMmT3V9u^U8=!<)fKP}kkoL-|KK|-o>)c(2hK4|Hb91+sSJeOi z|M%`a+7Rh+FDS~>+Tz}l>i<{w?yXL$_KG?i6?Kr~p(0QzV@Z%-FoVOh8)+a;lDE4H zN87!rRX`4ViKnkC`%6|S5m6rf@0Fhz7?@f-T^vI^j&Hr*-PdFwz#4Gs%$LT7U;piQ zf6ln7B(kg1w`X<*KXYo-jl>L|<;gp&=17;usLHTUIrt=M-ic~?rv#?ypENRMU6wn2 ziue@MY*+uKjy-|buC@PSy(ACoi*VjO{hGgg?IJiAWiFC>@U|>@BWuTHAEEh@55(>S zt?@qbgy{vRe^snm&2Z}^f8XrmT@jLk z=bwMxc<1@uKM#^lxPF=`sH-#Wcio+3L3;N&3UxL_yfDyDm=br)iDQMJdE(oA6$OSl zj3>0+K1FWn+99~3t;XZiN%jfP>bUnaA5)lr;I*-vTb%2@`F6SDeUCRRFjRQ%`Xl0+ z;Q8WDocs(P&Tf(27x$MnP1#mCJIXo2Van{otMzK+|8v~<*lD1;UwvQ0SyPKVF&j3u zCyN>xJ^uZO3uy>vTfgkF>`v)D%dR_+YbVY8p@ZD`3|a5}PG&q!|GI>t$i1de)73Y2Z6)=R zt8Xno?-F{@>kMwFx^mZVxG z7o`Fz1|tI_Q(Xg7T_dv)BLgcVLn}igZ36=<1B1`UANiqZ$jwj5OsmALp=nkWFvT-y hz-=hW%uOvWNz5(4t> = new Map(); + +/** + * Sky Battle encounter. + * @see {@link https://github.com/pagefaultgames/pokerogue/issues/5487 | GitHub Issue #5487} + * @see For biome requirements check {@linkcode mysteryEncountersByBiome} + */ +export const SkyBattleEncounter: MysteryEncounter = MysteryEncounterBuilder.withEncounterType( + MysteryEncounterType.SKY_BATTLE, +) + .withPrimaryPokemonRequirement(sky_battle_requirements) + .withMaxAllowedEncounters(1) + .withEncounterTier(MysteryEncounterTier.ULTRA) + .withSceneWaveRangeRequirement(...SKY_BATTLE_WAVES) + .withIntroSpriteConfigs([]) // Sprite is set in onInit() + .withIntroDialogue([ + { + text: `${namespace}:intro`, + }, + { + speaker: `${namespace}:speaker`, + text: `${namespace}:intro_dialogue`, + }, + ]) + .withOnInit(() => { + const encounter = globalScene.currentBattle.mysteryEncounter!; + const partySize: number = sky_battle_requirements.queryParty(globalScene.getPlayerParty()).length; + + // randomize trainer gender + const female = !!randSeedInt(2); + const config = getTrainerConfig(partySize, female); + const spriteKey = config.getSpriteKey(female); + encounter.enemyPartyConfigs.push({ + trainerConfig: config, + female: female, + }); + + // loads trainer sprite at start of encounter + encounter.spriteConfigs = [ + { + spriteKey: spriteKey, + fileRoot: "trainer", + hasShadow: true, + x: 4, + y: 7, + yShadow: 7, + }, + ]; + + return true; + }) + .setLocalizationKey(`${namespace}`) + .withTitle(`${namespace}:title`) + .withDescription(`${namespace}:description`) + .withQuery(`${namespace}:query`) + .withSimpleOption( + //Option 1: Battle + { + buttonLabel: `${namespace}:option.1.label`, + buttonTooltip: `${namespace}:option.1.tooltip`, + selected: [ + { + speaker: `${namespace}:speaker`, + text: `${namespace}:option.1.selected`, + }, + ], + }, + async () => { + // Select sky battle + const encounter = globalScene.currentBattle.mysteryEncounter!; + const config: EnemyPartyConfig = encounter.enemyPartyConfigs[0]; + + // Init the moves available for tutor + const moveTutorOptions: PokemonMove[] = []; + moveTutorOptions.push(new PokemonMove(PHYSICAL_TUTOR_MOVES[randSeedInt(PHYSICAL_TUTOR_MOVES.length)])); + moveTutorOptions.push(new PokemonMove(SPECIAL_TUTOR_MOVES[randSeedInt(SPECIAL_TUTOR_MOVES.length)])); + moveTutorOptions.push(new PokemonMove(SUPPORT_TUTOR_MOVES[randSeedInt(SUPPORT_TUTOR_MOVES.length)])); + encounter.misc = { + moveTutorOptions, + }; + + //Remove disallowed pokemon + const allowedPokemon = sky_battle_requirements.queryParty(globalScene.getPlayerParty()); + globalScene.getPlayerParty().filter(pokemon => !allowedPokemon.includes(pokemon)); + globalScene.getPlayerParty().map((pokemon, index) => { + if (!allowedPokemon.includes(pokemon)) { + disallowedPokemon.set(index, pokemon); + } + }); + + disallowedPokemon.forEach(pokemon => globalScene.removePokemonFromPlayerParty(pokemon, false)); + + //Set illegal pokemon moves pp to 0 + originalUsedPP = []; + globalScene.getPlayerParty().forEach(pokemon => + pokemon.moveset + .filter(move => INELIGIBLE_MOVES.includes(move.getMove().id)) + .forEach(move => { + originalUsedPP.push(move.ppUsed); + move.ppUsed = move.getMovePp(); + }), + ); + + // Assigns callback that teaches move before continuing to rewards + encounter.onRewards = doFlyingTypeTutor; + + setEncounterRewards({ fillRemaining: true }); + await transitionMysteryEncounterIntroVisuals(true, true); + await initBattleWithEnemyConfig(config); + + //Set illegal enemy pokemon moves pp to 0 + globalScene.getEnemyParty().forEach(pokemon => + pokemon.moveset + .filter(move => INELIGIBLE_MOVES.includes(move.getMove().id)) + .forEach(move => { + move.ppUsed = move.getMovePp(); + }), + ); + }, + ) + .withOption( + //Option 2: Flaunt flying pokemon + MysteryEncounterOptionBuilder.newOptionWithMode(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT) + .withPrimaryPokemonRequirement(sky_battle_requirements) // Must pass the same requirements to trigger this encounter + .withDialogue({ + buttonLabel: `${namespace}:option.2.label`, + buttonTooltip: `${namespace}:option.2.tooltip`, + disabledButtonTooltip: `${namespace}:option.2.disabled_tooltip`, + }) + .withPreOptionPhase(async () => { + // Player shows off their Flying pokemon + const encounter = globalScene.currentBattle.mysteryEncounter!; + + setEncounterRewards({ + guaranteedModifierTypeFuncs: [modifierTypes.QUICK_CLAW, modifierTypes.MAX_LURE, modifierTypes.ULTRA_BALL], + fillRemaining: false, + }); + encounter.selectedOption!.dialogue!.selected = [ + { + speaker: `${namespace}:speaker`, + text: `${namespace}:option.2.selected`, + }, + ]; + }) + .withOptionPhase(async () => { + // Player shows off their Flying pokémon + leaveEncounterWithoutBattle(); + }) + .build(), + ) + .withSimpleOption( + //Option 3: Reject battle and leave with no rewards + { + buttonLabel: `${namespace}:option.3.label`, + buttonTooltip: `${namespace}:option.3.tooltip`, + selected: [ + { + text: `${namespace}:option.3.selected`, + }, + ], + }, + async () => { + leaveEncounterWithoutBattle(); + return true; + }, + ) + .withOutroDialogue([ + { + text: `${namespace}:outro`, + }, + ]) + .build(); + +function getTrainerConfig(party_size: number, female: boolean): TrainerConfig { + // Sky trainer config + const config = trainerConfigs[TrainerType.SKY_TRAINER].clone(); + const name = female ? "sky_trainer_f" : "sky_trainer_m"; + config.name = i18next.t("trainerNames:" + name); + + let pool0Copy = POOL_0_POKEMON.slice(0); + pool0Copy = randSeedShuffle(pool0Copy); + let pool0Mon = pool0Copy.pop()!; + + config.setPartyTemplates(new TrainerPartyTemplate(party_size, PartyMemberStrength.STRONG)); + + // adds a non-repeating random pokemon + for (let index = 0; index < party_size; index++) { + config.setPartyMemberFunc(index, getRandomPartyMemberFunc([pool0Mon], TrainerSlot.TRAINER, true)); + pool0Mon = pool0Copy.pop()!; + } + + return config; +} + +function doFlyingTypeTutor(): Promise { + // biome-ignore lint/suspicious/noAsyncPromiseExecutor: TODO explain + return new Promise(async resolve => { + const moveOptions = globalScene.currentBattle.mysteryEncounter!.misc.moveTutorOptions; + await showEncounterDialogue(`${namespace}:battle_won`, `${namespace}:speaker`); + + const overlayScale = 1; + const moveInfoOverlay = new MoveInfoOverlay({ + delayVisibility: false, + scale: overlayScale, + onSide: true, + right: true, + x: 1, + y: -MoveInfoOverlay.getHeight(overlayScale, true) - 1, + width: globalScene.game.canvas.width / 6 - 2, + }); + globalScene.ui.add(moveInfoOverlay); + + const optionSelectItems = moveOptions.map((move: PokemonMove) => { + const option: OptionSelectItem = { + label: move.getName(), + handler: () => { + moveInfoOverlay.active = false; + moveInfoOverlay.setVisible(false); + return true; + }, + onHover: () => { + moveInfoOverlay.active = true; + moveInfoOverlay.show(allMoves[move.moveId]); + }, + }; + return option; + }); + + const onHoverOverCancel = () => { + moveInfoOverlay.active = false; + moveInfoOverlay.setVisible(false); + }; + + const result = await selectOptionThenPokemon( + optionSelectItems, + `${namespace}:teach_move_prompt`, + undefined, // No filter + onHoverOverCancel, + ); + if (!result) { + moveInfoOverlay.active = false; + moveInfoOverlay.setVisible(false); + } + // Option select complete, handle if they are learning a move + if (result && result.selectedOptionIndex < moveOptions.length) { + globalScene.unshiftPhase( + new LearnMovePhase(result.selectedPokemonIndex, moveOptions[result.selectedOptionIndex].moveId), + ); + } + + // Reset ineligible moves' pp + let idx = 0; + globalScene.getPlayerParty().forEach(pokemon => + pokemon.moveset + .filter(move => INELIGIBLE_MOVES.includes(move.getMove().id)) + .forEach(move => { + move.ppUsed = originalUsedPP[idx++]; + }), + ); + + //Return disallowed pokemons + disallowedPokemon.forEach((pokemon, index) => { + globalScene.getPlayerParty().splice(index, 0, pokemon); + }); + + // Complete battle and go to rewards + resolve(); + }); +} diff --git a/src/data/mystery-encounters/mystery-encounter-requirements.ts b/src/data/mystery-encounters/mystery-encounter-requirements.ts index a6e6e84846f..0913670eb9b 100644 --- a/src/data/mystery-encounters/mystery-encounter-requirements.ts +++ b/src/data/mystery-encounters/mystery-encounter-requirements.ts @@ -1213,3 +1213,51 @@ export class WeightRequirement extends EncounterPokemonRequirement { return ["weight", pokemon?.getWeight().toString() ?? ""]; } } + +/** + * This class tests for the minimum number of pokemon that passes any of the requirements + * E.g. having 3 pokemon that are either flying type, or have the levitate ability + */ +export class AnyCombinationPokemonRequirement extends EncounterPokemonRequirement { + private requirements: EncounterPokemonRequirement[]; + + constructor(minNumberOfPokemon: number, ...requirements: EncounterPokemonRequirement[]) { + super(); + this.invertQuery = false; + this.minNumberOfPokemon = minNumberOfPokemon; + this.requirements = requirements; + } + + /** + * Checks if at least {@linkcode minNumberOfPokemon} pokemon meet any of the requirements + * @returns true if at least {@linkcode minNumberOfPokemon} pokemon meet any of the requirements + */ + override meetsRequirement(): boolean { + const party = globalScene.getPlayerParty(); + return this.queryParty(party).length >= this.minNumberOfPokemon; + } + + /** + * Queries the players party for all party members that are compatible with any of the requirements + * @param partyPokemon The party of {@linkcode PlayerPokemon} + * @returns All party members that are compatible with any of the requirements + */ + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + return partyPokemon.filter(pokemon => this.requirements.some(req => req.queryParty([pokemon]).length !== 0)); + } + + /** + * Retrieves a dialogue token key/value pair for the given {@linkcode EncounterPokemonRequirement | requirements}. + * @param pokemon The {@linkcode PlayerPokemon} to check against + * @returns A dialogue token key/value pair + */ + override getDialogueToken(pokemon?: PlayerPokemon): [string, string] { + for (const req of this.requirements) { + if (req.meetsRequirement()) { + return req.getDialogueToken(pokemon); + } + } + + return this.requirements[0].getDialogueToken(pokemon); + } +} diff --git a/src/data/mystery-encounters/mystery-encounters.ts b/src/data/mystery-encounters/mystery-encounters.ts index 5ee289a6c56..40afad7edff 100644 --- a/src/data/mystery-encounters/mystery-encounters.ts +++ b/src/data/mystery-encounters/mystery-encounters.ts @@ -33,6 +33,7 @@ import { UncommonBreedEncounter } from "#app/data/mystery-encounters/encounters/ import { GlobalTradeSystemEncounter } from "#app/data/mystery-encounters/encounters/global-trade-system-encounter"; import { TheExpertPokemonBreederEncounter } from "#app/data/mystery-encounters/encounters/the-expert-pokemon-breeder-encounter"; import { getBiomeName } from "#app/data/balance/biomes"; +import { SkyBattleEncounter } from "./encounters/sky-battle-encounter"; export const EXTREME_ENCOUNTER_BIOMES = [ BiomeId.SEA, @@ -135,6 +136,36 @@ export const CIVILIZATION_ENCOUNTER_BIOMES = [ BiomeId.ISLAND, ]; +/** + * Places where you could fly like a bird + */ +export const OPEN_SKY_BIOMES = [ + BiomeId.TOWN, + BiomeId.PLAINS, + BiomeId.GRASS, + BiomeId.TALL_GRASS, + BiomeId.METROPOLIS, + BiomeId.FOREST, + BiomeId.SEA, + BiomeId.SWAMP, + BiomeId.BEACH, + BiomeId.LAKE, + BiomeId.MOUNTAIN, + BiomeId.BADLANDS, + BiomeId.DESERT, + BiomeId.MEADOW, + BiomeId.VOLCANO, + BiomeId.GRAVEYARD, + BiomeId.RUINS, + BiomeId.WASTELAND, + BiomeId.CONSTRUCTION_SITE, + BiomeId.JUNGLE, + BiomeId.TEMPLE, + BiomeId.SLUM, + BiomeId.SNOWY_FOREST, + BiomeId.ISLAND, +]; + export const allMysteryEncounters: { [encounterType: number]: MysteryEncounter; } = {}; @@ -162,6 +193,8 @@ const civilizationBiomeEncounters: MysteryEncounterType[] = [ MysteryEncounterType.GLOBAL_TRADE_SYSTEM, ]; +const openSkyBiomeEncounters: MysteryEncounterType[] = [MysteryEncounterType.SKY_BATTLE]; + /** * To add an encounter to every biome possible, use this array */ @@ -257,6 +290,7 @@ export function initMysteryEncounters() { allMysteryEncounters[MysteryEncounterType.UNCOMMON_BREED] = UncommonBreedEncounter; allMysteryEncounters[MysteryEncounterType.GLOBAL_TRADE_SYSTEM] = GlobalTradeSystemEncounter; allMysteryEncounters[MysteryEncounterType.THE_EXPERT_POKEMON_BREEDER] = TheExpertPokemonBreederEncounter; + allMysteryEncounters[MysteryEncounterType.SKY_BATTLE] = SkyBattleEncounter; // Add extreme encounters to biome map extremeBiomeEncounters.forEach(encounter => { @@ -295,6 +329,16 @@ export function initMysteryEncounters() { }); }); + // add open sky encounters to biome map + openSkyBiomeEncounters.forEach(encounter => { + OPEN_SKY_BIOMES.forEach(biome => { + const encountersForBiome = mysteryEncountersByBiome.get(biome); + if (encountersForBiome && !encountersForBiome.includes(encounter)) { + encountersForBiome.push(encounter); + } + }); + }); + // Add ANY biome encounters to biome map let _encounterBiomeTableLog = ""; mysteryEncountersByBiome.forEach((biomeEncounters, biome) => { diff --git a/src/data/trainers/trainer-config.ts b/src/data/trainers/trainer-config.ts index 063dddafee8..9381733881e 100644 --- a/src/data/trainers/trainer-config.ts +++ b/src/data/trainers/trainer-config.ts @@ -6045,4 +6045,9 @@ export const trainerConfigs: TrainerConfigs = { .setVictoryBgm("mystery_encounter_weird_dream") .setLocalizedName("Future Self F") .setPartyTemplates(new TrainerPartyTemplate(6, PartyMemberStrength.STRONG)), + [TrainerType.SKY_TRAINER]: new TrainerConfig(++t) + .setHasGenders("Sky Trainer Felicia") + .setMoneyMultiplier(2.25) + .setEncounterBgm(TrainerType.ACE_TRAINER) + .setPartyTemplates(new TrainerPartyTemplate(6, PartyMemberStrength.STRONG)), }; diff --git a/src/enums/mystery-encounter-type.ts b/src/enums/mystery-encounter-type.ts index b973652b113..546549e40a5 100644 --- a/src/enums/mystery-encounter-type.ts +++ b/src/enums/mystery-encounter-type.ts @@ -29,5 +29,6 @@ export enum MysteryEncounterType { FUN_AND_GAMES, UNCOMMON_BREED, GLOBAL_TRADE_SYSTEM, - THE_EXPERT_POKEMON_BREEDER + THE_EXPERT_POKEMON_BREEDER, + SKY_BATTLE, } diff --git a/src/enums/trainer-type.ts b/src/enums/trainer-type.ts index e22dc5d81c7..130f338dcb6 100644 --- a/src/enums/trainer-type.ts +++ b/src/enums/trainer-type.ts @@ -118,6 +118,7 @@ export enum TrainerType { EXPERT_POKEMON_BREEDER, FUTURE_SELF_M, FUTURE_SELF_F, + SKY_TRAINER, BROCK = 200, MISTY, diff --git a/src/plugins/i18n.ts b/src/plugins/i18n.ts index 515d9aec528..2a9a072fd9e 100644 --- a/src/plugins/i18n.ts +++ b/src/plugins/i18n.ts @@ -174,7 +174,24 @@ export async function initI18n(): Promise { "es-MX": ["es-ES", "en"], default: ["en"], }, - supportedLngs: ["en", "es-ES", "es-MX", "fr", "it", "de", "zh-CN", "zh-TW", "pt-BR", "ko", "ja", "ca", "da", "tr", "ro", "ru"], + supportedLngs: [ + "en", + "es-ES", + "es-MX", + "fr", + "it", + "de", + "zh-CN", + "zh-TW", + "pt-BR", + "ko", + "ja", + "ca", + "da", + "tr", + "ro", + "ru", + ], backend: { loadPath(lng: string, [ns]: string[]) { let fileName: string; @@ -276,6 +293,7 @@ export async function initI18n(): Promise { "mysteryEncounters/theWinstrateChallenge", "mysteryEncounters/teleportingHijinks", "mysteryEncounters/bugTypeSuperfan", + "mysteryEncounters/skyBattle", "mysteryEncounters/funAndGames", "mysteryEncounters/uncommonBreed", "mysteryEncounters/globalTradeSystem", diff --git a/test/mystery-encounter/encounters/sky-battle-encounter.test.ts b/test/mystery-encounter/encounters/sky-battle-encounter.test.ts new file mode 100644 index 00000000000..accc942f94c --- /dev/null +++ b/test/mystery-encounter/encounters/sky-battle-encounter.test.ts @@ -0,0 +1,408 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { BiomeId } from "#app/enums/biome-id"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { SpeciesId } from "#app/enums/species-id"; +import GameManager from "#test/testUtils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + runMysteryEncounterToEnd, + runSelectMysteryEncounterOption, + skipBattleRunMysteryEncounterRewardsPhase, +} from "#test/mystery-encounter/encounter-test-utils"; +import { MoveId } from "#enums/move-id"; +import type BattleScene from "#app/battle-scene"; +import { PokemonMove } from "#app/field/pokemon"; +import { UiMode } from "#enums/ui-mode"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/testUtils/gameManagerUtils"; +import { TrainerType } from "#enums/trainer-type"; +import { MysteryEncounterPhase, MysteryEncounterRewardsPhase } from "#app/phases/mystery-encounter-phases"; +import { CommandPhase } from "#app/phases/command-phase"; +import * as encounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { SelectModifierPhase } from "#app/phases/select-modifier-phase"; +import ModifierSelectUiHandler from "#app/ui/modifier-select-ui-handler"; +import { SkyBattleEncounter } from "#app/data/mystery-encounters/encounters/sky-battle-encounter"; +import { Button } from "#enums/buttons"; + +const namespace = "mysteryEncounters/skyBattle"; +const defaultParty = [SpeciesId.RAYQUAZA, SpeciesId.WEEDLE, SpeciesId.FLYGON, SpeciesId.RATTATA, SpeciesId.AERODACTYL]; +const defaultBiome = BiomeId.BEACH; +const defaultWave = 52; + +const POOL_0_POKEMON = [ + SpeciesId.CHARIZARD, + SpeciesId.BUTTERFREE, + SpeciesId.PIDGEOTTO, + SpeciesId.PIDGEOT, + SpeciesId.FEAROW, + SpeciesId.ZUBAT, + SpeciesId.GOLBAT, + SpeciesId.HAUNTER, + SpeciesId.KOFFING, + SpeciesId.WEEZING, + SpeciesId.SCYTHER, + SpeciesId.GYARADOS, + SpeciesId.AERODACTYL, + SpeciesId.ARTICUNO, + SpeciesId.ZAPDOS, + SpeciesId.MOLTRES, + SpeciesId.DRAGONITE, + SpeciesId.NOCTOWL, + SpeciesId.LEDYBA, + SpeciesId.LEDIAN, + SpeciesId.CROBAT, + SpeciesId.TOGETIC, + SpeciesId.XATU, + SpeciesId.HOPPIP, + SpeciesId.SKIPLOOM, + SpeciesId.JUMPLUFF, + SpeciesId.YANMA, + SpeciesId.MISDREAVUS, + SpeciesId.UNOWN, + SpeciesId.GLIGAR, + SpeciesId.MANTINE, + SpeciesId.SKARMORY, + SpeciesId.LUGIA, + SpeciesId.HO_OH, + SpeciesId.BEAUTIFLY, + SpeciesId.SWELLOW, + SpeciesId.WINGULL, + SpeciesId.PELIPPER, + SpeciesId.MASQUERAIN, + SpeciesId.NINJASK, + SpeciesId.VIBRAVA, + SpeciesId.FLYGON, + SpeciesId.SWABLU, + SpeciesId.ALTARIA, + SpeciesId.LUNATONE, + SpeciesId.SOLROCK, + SpeciesId.BALTOY, + SpeciesId.CLAYDOL, + SpeciesId.DUSKULL, + SpeciesId.TROPIUS, + SpeciesId.CHIMECHO, + SpeciesId.SALAMENCE, + SpeciesId.LATIAS, + SpeciesId.LATIOS, + SpeciesId.RAYQUAZA, + SpeciesId.STARAVIA, + SpeciesId.STARAPTOR, + SpeciesId.MOTHIM, + SpeciesId.COMBEE, + SpeciesId.VESPIQUEN, + SpeciesId.DRIFLOON, + SpeciesId.DRIFBLIM, + SpeciesId.MISMAGIUS, + SpeciesId.HONCHKROW, + SpeciesId.CHINGLING, + SpeciesId.BRONZOR, + SpeciesId.BRONZONG, + SpeciesId.CARNIVINE, + SpeciesId.MANTYKE, + SpeciesId.TOGEKISS, + SpeciesId.YANMEGA, + SpeciesId.GLISCOR, + SpeciesId.ROTOM, + SpeciesId.UXIE, + SpeciesId.MESPRIT, + SpeciesId.AZELF, + SpeciesId.CRESSELIA, + SpeciesId.TRANQUILL, + SpeciesId.UNFEZANT, + SpeciesId.WOOBAT, + SpeciesId.SWOOBAT, + SpeciesId.SIGILYPH, + SpeciesId.ARCHEOPS, + SpeciesId.SWANNA, + SpeciesId.EMOLGA, + SpeciesId.TYNAMO, + SpeciesId.EELEKTRIK, + SpeciesId.EELEKTROSS, + SpeciesId.CRYOGONAL, + SpeciesId.BRAVIARY, + SpeciesId.MANDIBUZZ, + SpeciesId.HYDREIGON, + SpeciesId.TORNADUS, + SpeciesId.THUNDURUS, + SpeciesId.LANDORUS, + SpeciesId.FLETCHINDER, + SpeciesId.TALONFLAME, + SpeciesId.VIVILLON, + SpeciesId.NOIBAT, + SpeciesId.NOIVERN, + SpeciesId.YVELTAL, +]; + +const PHYSICAL_TUTOR_MOVES = [ + MoveId.FLY, + MoveId.BRAVE_BIRD, + MoveId.ACROBATICS, + MoveId.DRAGON_ASCENT, + MoveId.BEAK_BLAST, + MoveId.FLOATY_FALL, + MoveId.DUAL_WINGBEAT, +]; + +const SPECIAL_TUTOR_MOVES = [MoveId.AEROBLAST, MoveId.AIR_SLASH, MoveId.HURRICANE, MoveId.BLEAKWIND_STORM]; + +const SUPPORT_TUTOR_MOVES = [MoveId.FEATHER_DANCE, MoveId.ROOST, MoveId.PLUCK, MoveId.TAILWIND]; + +describe("Sky Battle - 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.startingWave(defaultWave); + game.override.startingBiome(defaultBiome); + game.override.disableTrainerWaves(); + + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([[BiomeId.BEACH, [MysteryEncounterType.SKY_BATTLE]]]), + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.SKY_BATTLE, defaultParty); + + expect(SkyBattleEncounter.encounterType).toBe(MysteryEncounterType.SKY_BATTLE); + expect(SkyBattleEncounter.encounterTier).toBe(MysteryEncounterTier.ULTRA); + expect(SkyBattleEncounter.dialogue).toBeDefined(); + expect(SkyBattleEncounter.dialogue.intro).toStrictEqual([ + { + text: `${namespace}:intro`, + }, + { + speaker: `${namespace}:speaker`, + text: `${namespace}:intro_dialogue`, + }, + ]); + expect(SkyBattleEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}:title`); + expect(SkyBattleEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}:description`); + expect(SkyBattleEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}:query`); + expect(SkyBattleEncounter.options.length).toBe(3); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = SkyBattleEncounter; + + const { onInit } = SkyBattleEncounter; + + expect(SkyBattleEncounter.onInit).toBeDefined(); + + SkyBattleEncounter.populateDialogueTokensFromRequirements(); + const onInitResult = onInit!(); + const config = SkyBattleEncounter.enemyPartyConfigs[0]; + + expect(config).toBeDefined(); + expect(config.trainerConfig?.trainerType).toBe(TrainerType.SKY_TRAINER); + expect(config.trainerConfig?.partyTemplates).toBeDefined(); + // Allows any gender (randomized) + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Battle the Sky Trainer", () => { + it("should have the correct properties", () => { + const option = SkyBattleEncounter.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 battle against the Sky Trainer", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.SKY_BATTLE, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + + const enemyParty = scene.getEnemyParty(); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + expect(scene.currentBattle.trainer?.config.trainerType).toBe(TrainerType.SKY_TRAINER); + //Ensure the number of enemy pokemon match our party + expect(enemyParty.length).toBe(scene.getPlayerParty().length); + expect(enemyParty.every(pkm => POOL_0_POKEMON.includes(pkm.species.speciesId))); + }); + + it("should zero disallowed moves' pp", async () => { + game.override.moveset([MoveId.DRAGON_CLAW, MoveId.EARTHQUAKE]); + await game.runToMysteryEncounter(MysteryEncounterType.SKY_BATTLE, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + await skipBattleRunMysteryEncounterRewardsPhase(game, false); + + // Only allow acceptable moves (setting available pp to 0) + const moveGood = scene.getPlayerParty()[0].getMoveset()[0]; + const moveBad = scene.getPlayerParty()[0].getMoveset()[1]; + expect(moveBad.ppUsed).toBe(moveBad.getMovePp()); + expect(moveGood.ppUsed).toBe(0); + + game.phaseInterceptor["prompts"] = []; // Clear out prompt handlers + game.onNextPrompt("MysteryEncounterRewardsPhase", UiMode.OPTION_SELECT, () => { + game.scene.ui.setCursor(3); + game.scene.ui.processInput(Button.ACTION); + }); + await game.phaseInterceptor.run(MysteryEncounterRewardsPhase); + + // Return unacceptable moves' pp + const moveBadAfter = scene.getPlayerParty()[0].getMoveset()[1]; + expect(moveBadAfter.ppUsed).toBe(0); + }); + + it("should remove ineligeble pokemon from player party", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.SKY_BATTLE, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + expect(scene.getCurrentPhase()?.constructor.name).toBe(CommandPhase.name); + await skipBattleRunMysteryEncounterRewardsPhase(game, false); + + // Only allow acceptable pokemon + expect(scene.getPlayerParty().length).toBe(3); // we have 2 ineligle pokemon in the default party + expect( + scene + .getPlayerParty() + .every(pokemon => ![SpeciesId.WEEDLE, SpeciesId.RATTATA].includes(pokemon.species.speciesId)), + ).toBe(true); + + game.phaseInterceptor["prompts"] = []; // Clear out prompt handlers + game.onNextPrompt("MysteryEncounterRewardsPhase", UiMode.OPTION_SELECT, () => { + game.scene.ui.setCursor(3); + game.scene.ui.processInput(Button.ACTION); + }); + await game.phaseInterceptor.run(MysteryEncounterRewardsPhase); + + // Return unacceptable pokemons to party + expect(scene.getPlayerParty().length).toBe(defaultParty.length); + expect(scene.getPlayerParty()[1].species.speciesId).toBe(SpeciesId.WEEDLE); + expect(scene.getPlayerParty()[3].species.speciesId).toBe(SpeciesId.RATTATA); + }); + + it("should let the player learn a Flying move after battle ends", async () => { + const selectOptionSpy = vi.spyOn(encounterPhaseUtils, "selectOptionThenPokemon"); + await game.runToMysteryEncounter(MysteryEncounterType.SKY_BATTLE, defaultParty); + await runMysteryEncounterToEnd(game, 1, undefined, true); + await skipBattleRunMysteryEncounterRewardsPhase(game, false); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterRewardsPhase.name); + game.phaseInterceptor["prompts"] = []; // Clear out prompt handlers + game.onNextPrompt("MysteryEncounterRewardsPhase", UiMode.OPTION_SELECT, () => { + game.phaseInterceptor.superEndPhase(); + }); + await game.phaseInterceptor.run(MysteryEncounterRewardsPhase); + + expect(selectOptionSpy).toHaveBeenCalledTimes(1); + const optionData = selectOptionSpy.mock.calls[0][0]; + expect(PHYSICAL_TUTOR_MOVES.some(move => new PokemonMove(move).getName() === optionData[0].label)).toBe(true); + expect(SPECIAL_TUTOR_MOVES.some(move => new PokemonMove(move).getName() === optionData[1].label)).toBe(true); + expect(SUPPORT_TUTOR_MOVES.some(move => new PokemonMove(move).getName() === optionData[2].label)).toBe(true); + }); + }); + + describe("Option 2 - Show off Flying Types", () => { + it("should have the correct properties", () => { + const option = SkyBattleEncounter.options[1]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}:option.2.label`, + buttonTooltip: `${namespace}:option.2.tooltip`, + disabledButtonTooltip: `${namespace}:option.2.disabled_tooltip`, + }); + }); + + it("should NOT be selectable if the player doesn't have enough Flying pokemon", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.SKY_BATTLE, [ + SpeciesId.ABRA, + SpeciesId.PIDGEY, + SpeciesId.SPEAROW, + ]); + await game.phaseInterceptor.to(MysteryEncounterPhase, false); + + const encounterPhase = scene.getCurrentPhase(); + expect(encounterPhase?.constructor.name).toBe(MysteryEncounterPhase.name); + const mysteryEncounterPhase = encounterPhase as MysteryEncounterPhase; + vi.spyOn(mysteryEncounterPhase, "continueEncounter"); + vi.spyOn(mysteryEncounterPhase, "handleOptionSelect"); + vi.spyOn(scene.ui, "playError"); + + await runSelectMysteryEncounterOption(game, 2); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(MysteryEncounterPhase.name); + expect(scene.ui.playError).not.toHaveBeenCalled(); // No error sfx, option is disabled + expect(mysteryEncounterPhase.handleOptionSelect).not.toHaveBeenCalled(); + expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); + }); + + it("should proceed to rewards screen with reward options", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.SKY_BATTLE, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(scene.getCurrentPhase()?.constructor.name).toBe(SelectModifierPhase.name); + await game.phaseInterceptor.run(SelectModifierPhase); + + expect(scene.ui.getMode()).to.equal(UiMode.MODIFIER_SELECT); + const modifierSelectHandler = scene.ui.handlers.find( + h => h instanceof ModifierSelectUiHandler, + ) as ModifierSelectUiHandler; + expect(modifierSelectHandler.options.length).toEqual(3); + expect(modifierSelectHandler.options[0].modifierTypeOption.type.id).toBe("QUICK_CLAW"); + expect(modifierSelectHandler.options[1].modifierTypeOption.type.id).toBe("MAX_LURE"); + expect(modifierSelectHandler.options[2].modifierTypeOption.type.id).toBe("ULTRA_BALL"); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(encounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.SKY_BATTLE, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); + + describe("Option 3 - Reject battle", () => { + it("should have the correct properties", async () => { + const option = SkyBattleEncounter.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: [ + { + text: `${namespace}:option.3.selected`, + }, + ], + }); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(encounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.SKY_BATTLE, [SpeciesId.RAYQUAZA]); + await runMysteryEncounterToEnd(game, 3); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +});