diff --git a/public/audio/bgm/forest.mp3 b/public/audio/bgm/forest.mp3 index a1d9ecb4b28..6382d3fc0b2 100644 Binary files a/public/audio/bgm/forest.mp3 and b/public/audio/bgm/forest.mp3 differ diff --git a/src/data/balance/special-species-groups.ts b/src/data/balance/special-species-groups.ts new file mode 100644 index 00000000000..eeba96595a6 --- /dev/null +++ b/src/data/balance/special-species-groups.ts @@ -0,0 +1,46 @@ +import { Species } from "#enums/species"; + +/** + * A list of all {@link https://bulbapedia.bulbagarden.net/wiki/Paradox_Pok%C3%A9mon | Paradox Pokemon}, NOT including the legendaries Miraidon and Koraidon. + */ +export const NON_LEGEND_PARADOX_POKEMON = [ + Species.GREAT_TUSK, + Species.SCREAM_TAIL, + Species.BRUTE_BONNET, + Species.FLUTTER_MANE, + Species.SLITHER_WING, + Species.SANDY_SHOCKS, + Species.ROARING_MOON, + Species.WALKING_WAKE, + Species.GOUGING_FIRE, + Species.RAGING_BOLT, + Species.IRON_TREADS, + Species.IRON_BUNDLE, + Species.IRON_HANDS, + Species.IRON_JUGULIS, + Species.IRON_MOTH, + Species.IRON_THORNS, + Species.IRON_VALIANT, + Species.IRON_LEAVES, + Species.IRON_BOULDER, + Species.IRON_CROWN, +]; + +/** + * A list of all {@link https://bulbapedia.bulbagarden.net/wiki/Ultra_Beast | Ultra Beasts}, NOT including legendaries such as Necrozma or the Cosmog line. + * + * Note that all of these Ultra Beasts are still considered Sub-Legendary. + */ +export const NON_LEGEND_ULTRA_BEASTS = [ + Species.NIHILEGO, + Species.BUZZWOLE, + Species.PHEROMOSA, + Species.XURKITREE, + Species.CELESTEELA, + Species.KARTANA, + Species.GUZZLORD, + Species.POIPOLE, + Species.NAGANADEL, + Species.STAKATAKA, + Species.BLACEPHALON, +]; diff --git a/src/data/move.ts b/src/data/move.ts index 9696e2e4d53..64f8e43cb7b 100644 --- a/src/data/move.ts +++ b/src/data/move.ts @@ -1385,14 +1385,38 @@ export class UserHpDamageAttr extends FixedDamageAttr { } export class TargetHalfHpDamageAttr extends FixedDamageAttr { + // the initial amount of hp the target had before the first hit + // used for multi lens + private initialHp: number; constructor() { super(0); } apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { - (args[0] as Utils.IntegerHolder).value = Utils.toDmgValue(target.hp / 2); + // first, determine if the hit is coming from multi lens or not + const lensCount = user.getHeldItems().find(i => i instanceof PokemonMultiHitModifier)?.getStackCount() ?? 0; + if (lensCount <= 0) { + // no multi lenses; we can just halve the target's hp and call it a day + (args[0] as Utils.NumberHolder).value = Utils.toDmgValue(target.hp / 2); + return true; + } - return true; + // figure out what hit # we're on + switch (user.turnData.hitCount - user.turnData.hitsLeft) { + case 0: + // first hit of move; update initialHp tracker + this.initialHp = target.hp; + default: + // multi lens added hit; use initialHp tracker to ensure correct damage + (args[0] as Utils.NumberHolder).value = Utils.toDmgValue(this.initialHp / 2); + return true; + break; + case lensCount + 1: + // parental bond added hit; calc damage as normal + (args[0] as Utils.NumberHolder).value = Utils.toDmgValue(target.hp / 2); + return true; + break; + } } getTargetBenefitScore(user: Pokemon, target: Pokemon, move: Move): number { @@ -5975,14 +5999,22 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { } } - if (switchOutTarget.scene.getPlayerParty().filter((p) => p.isAllowedInBattle() && !p.isOnField()).length < 1) { + // Find indices of off-field Pokemon that are eligible to be switched into + const eligibleNewIndices: number[] = []; + switchOutTarget.scene.getPlayerParty().forEach((pokemon, index) => { + if (pokemon.isAllowedInBattle() && !pokemon.isOnField()) { + eligibleNewIndices.push(index); + } + }); + + if (eligibleNewIndices.length < 1) { return false; } if (switchOutTarget.hp > 0) { if (this.switchType === SwitchType.FORCE_SWITCH) { switchOutTarget.leaveField(true); - const slotIndex = Utils.randIntRange(user.scene.currentBattle.getBattlerCount(), user.scene.getPlayerParty().length); + const slotIndex = eligibleNewIndices[user.randSeedInt(eligibleNewIndices.length)]; user.scene.prependToPhase( new SwitchSummonPhase( user.scene, @@ -6011,14 +6043,22 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { } return false; } else if (user.scene.currentBattle.battleType !== BattleType.WILD) { // Switch out logic for enemy trainers - if (switchOutTarget.scene.getEnemyParty().filter((p) => p.isAllowedInBattle() && !p.isOnField()).length < 1) { + // Find indices of off-field Pokemon that are eligible to be switched into + const eligibleNewIndices: number[] = []; + switchOutTarget.scene.getEnemyParty().forEach((pokemon, index) => { + if (pokemon.isAllowedInBattle() && !pokemon.isOnField()) { + eligibleNewIndices.push(index); + } + }); + + if (eligibleNewIndices.length < 1) { return false; } if (switchOutTarget.hp > 0) { if (this.switchType === SwitchType.FORCE_SWITCH) { switchOutTarget.leaveField(true); - const slotIndex = Utils.randIntRange(user.scene.currentBattle.getBattlerCount(), user.scene.getEnemyParty().length); + const slotIndex = eligibleNewIndices[user.randSeedInt(eligibleNewIndices.length)]; user.scene.prependToPhase( new SwitchSummonPhase( user.scene, diff --git a/src/data/mystery-encounters/encounters/dark-deal-encounter.ts b/src/data/mystery-encounters/encounters/dark-deal-encounter.ts index 2b801d70f61..05d6501f256 100644 --- a/src/data/mystery-encounters/encounters/dark-deal-encounter.ts +++ b/src/data/mystery-encounters/encounters/dark-deal-encounter.ts @@ -8,7 +8,7 @@ import { getPokemonSpecies } from "#app/data/pokemon-species"; import MysteryEncounter, { MysteryEncounterBuilder } from "#app/data/mystery-encounters/mystery-encounter"; import { MysteryEncounterOptionBuilder } from "#app/data/mystery-encounters/mystery-encounter-option"; import { EnemyPartyConfig, EnemyPokemonConfig, initBattleWithEnemyConfig, leaveEncounterWithoutBattle, } from "../utils/encounter-phase-utils"; -import { getRandomPlayerPokemon, getRandomSpeciesByStarterTier } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { getRandomPlayerPokemon, getRandomSpeciesByStarterCost } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; import { ModifierRewardPhase } from "#app/phases/modifier-reward-phase"; @@ -174,7 +174,7 @@ export const DarkDealEncounter: MysteryEncounter = const roll = randSeedInt(100); const starterTier: number | [number, number] = roll >= 65 ? 6 : roll >= 15 ? 7 : roll >= 5 ? 8 : [ 9, 10 ]; - const bossSpecies = getPokemonSpecies(getRandomSpeciesByStarterTier(starterTier, excludedBosses, bossTypes)); + const bossSpecies = getPokemonSpecies(getRandomSpeciesByStarterCost(starterTier, excludedBosses, bossTypes)); const pokemonConfig: EnemyPokemonConfig = { species: bossSpecies, isBoss: true, diff --git a/src/data/mystery-encounters/encounters/safari-zone-encounter.ts b/src/data/mystery-encounters/encounters/safari-zone-encounter.ts index 0353d52a592..e16cf2d6973 100644 --- a/src/data/mystery-encounters/encounters/safari-zone-encounter.ts +++ b/src/data/mystery-encounters/encounters/safari-zone-encounter.ts @@ -9,9 +9,9 @@ import { EnemyPokemon } from "#app/field/pokemon"; import { PokeballType } from "#enums/pokeball"; import { PlayerGender } from "#enums/player-gender"; import { IntegerHolder, randSeedInt } from "#app/utils"; -import { getPokemonSpecies } from "#app/data/pokemon-species"; +import PokemonSpecies, { getPokemonSpecies } from "#app/data/pokemon-species"; import { MoneyRequirement } from "#app/data/mystery-encounters/mystery-encounter-requirements"; -import { doPlayerFlee, doPokemonFlee, getRandomSpeciesByStarterTier, trainerThrowPokeball } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { doPlayerFlee, doPokemonFlee, getRandomSpeciesByStarterCost, trainerThrowPokeball } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; import { getEncounterText, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; import { getPokemonNameWithAffix } from "#app/messages"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; @@ -19,6 +19,7 @@ import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode import { ScanIvsPhase } from "#app/phases/scan-ivs-phase"; import { SummonPhase } from "#app/phases/summon-phase"; import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; +import { NON_LEGEND_PARADOX_POKEMON } from "#app/data/balance/special-species-groups"; /** the i18n namespace for the encounter */ const namespace = "mysteryEncounters/safariZone"; @@ -261,7 +262,7 @@ async function summonSafariPokemon(scene: BattleScene) { let enemySpecies; let pokemon; scene.executeWithSeedOffset(() => { - enemySpecies = getPokemonSpecies(getRandomSpeciesByStarterTier([ 0, 5 ], undefined, undefined, false, false, false)); + enemySpecies = getSafariSpeciesSpawn(); const level = scene.currentBattle.getLevelForWave(); enemySpecies = getPokemonSpecies(enemySpecies.getWildSpeciesForLevel(level, true, false, scene.gameMode)); pokemon = scene.addEnemyPokemon(enemySpecies, level, TrainerSlot.NONE, false); @@ -526,3 +527,10 @@ async function doEndTurn(scene: BattleScene, cursorIndex: number) { initSubsequentOptionSelect(scene, { overrideOptions: safariZoneGameOptions, startingCursorIndex: cursorIndex, hideDescription: true }); } } + +/** + * @returns A random species that has at most 5 starter cost and is not Mythical, Paradox, etc. + */ +export function getSafariSpeciesSpawn(): PokemonSpecies { + return getPokemonSpecies(getRandomSpeciesByStarterCost([ 0, 5 ], NON_LEGEND_PARADOX_POKEMON, undefined, false, false, false)); +} diff --git a/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts b/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts index 77d92e3749e..61a4de908e9 100644 --- a/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts +++ b/src/data/mystery-encounters/encounters/the-pokemon-salesman-encounter.ts @@ -4,8 +4,8 @@ import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import BattleScene from "#app/battle-scene"; import MysteryEncounter, { MysteryEncounterBuilder } from "#app/data/mystery-encounters/mystery-encounter"; import { MoneyRequirement } from "#app/data/mystery-encounters/mystery-encounter-requirements"; -import { catchPokemon, getRandomSpeciesByStarterTier, getSpriteKeysFromPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; -import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { catchPokemon, getRandomSpeciesByStarterCost, getSpriteKeysFromPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import PokemonSpecies, { getPokemonSpecies } from "#app/data/pokemon-species"; import { speciesStarterCosts } from "#app/data/balance/starters"; import { Species } from "#enums/species"; import { PokeballType } from "#enums/pokeball"; @@ -17,6 +17,7 @@ import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; import { CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES } from "#app/game-mode"; import { Abilities } from "#enums/abilities"; +import { NON_LEGEND_PARADOX_POKEMON } from "#app/data/balance/special-species-groups"; /** the i18n namespace for this encounter */ const namespace = "mysteryEncounters/thePokemonSalesman"; @@ -60,12 +61,12 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = .withOnInit((scene: BattleScene) => { const encounter = scene.currentBattle.mysteryEncounter!; - let species = getPokemonSpecies(getRandomSpeciesByStarterTier([ 0, 5 ], undefined, undefined, false, false, false)); + let species = getSalesmanSpeciesOffer(); let tries = 0; // Reroll any species that don't have HAs while ((isNullOrUndefined(species.abilityHidden) || species.abilityHidden === Abilities.NONE) && tries < 5) { - species = getPokemonSpecies(getRandomSpeciesByStarterTier([ 0, 5 ], undefined, undefined, false, false, false)); + species = getSalesmanSpeciesOffer(); tries++; } @@ -164,3 +165,10 @@ export const ThePokemonSalesmanEncounter: MysteryEncounter = } ) .build(); + +/** + * @returns A random species that has at most 5 starter cost and is not Mythical, Paradox, etc. + */ +export function getSalesmanSpeciesOffer(): PokemonSpecies { + return getPokemonSpecies(getRandomSpeciesByStarterCost([ 0, 5 ], NON_LEGEND_PARADOX_POKEMON, undefined, false, false, false)); +} diff --git a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts index fc85754bdde..7adcfdc4974 100644 --- a/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-pokemon-utils.ts @@ -207,7 +207,7 @@ export function getHighestStatTotalPlayerPokemon(scene: BattleScene, isAllowed: * @param allowMythical * @returns */ -export function getRandomSpeciesByStarterTier(starterTiers: number | [number, number], excludedSpecies?: Species[], types?: Type[], allowSubLegendary: boolean = true, allowLegendary: boolean = true, allowMythical: boolean = true): Species { +export function getRandomSpeciesByStarterCost(starterTiers: number | [number, number], excludedSpecies?: Species[], types?: Type[], allowSubLegendary: boolean = true, allowLegendary: boolean = true, allowMythical: boolean = true): Species { let min = Array.isArray(starterTiers) ? starterTiers[0] : starterTiers; let max = Array.isArray(starterTiers) ? starterTiers[1] : starterTiers; diff --git a/src/field/arena.ts b/src/field/arena.ts index da74063ac06..3cbef659d7a 100644 --- a/src/field/arena.ts +++ b/src/field/arena.ts @@ -707,7 +707,7 @@ export class Arena { case Biome.METROPOLIS: return 141.470; case Biome.FOREST: - return 4.294; + return 0.341; case Biome.SEA: return 0.024; case Biome.SWAMP: diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 0f322968d2e..8409c1957e8 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2618,8 +2618,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { }; } - // If the attack deals fixed damaged, return a result with that much damage - const fixedDamage = new Utils.IntegerHolder(0); + // If the attack deals fixed damage, return a result with that much damage + const fixedDamage = new Utils.NumberHolder(0); applyMoveAttrs(FixedDamageAttr, source, this, move, fixedDamage); if (fixedDamage.value) { const multiLensMultiplier = new Utils.NumberHolder(1); diff --git a/src/phases/egg-lapse-phase.ts b/src/phases/egg-lapse-phase.ts index 4c57be09b79..8de26ee3ef0 100644 --- a/src/phases/egg-lapse-phase.ts +++ b/src/phases/egg-lapse-phase.ts @@ -34,7 +34,7 @@ export class EggLapsePhase extends Phase { if (eggsToHatchCount >= this.minEggsToSkip && this.scene.eggSkipPreference === 1) { this.scene.ui.showText(i18next.t("battle:eggHatching"), 0, () => { // show prompt for skip, blocking inputs for 1 second - this.scene.ui.showText(i18next.t("battle:eggSkipPrompt"), 0); + this.scene.ui.showText(i18next.t("battle:eggSkipPrompt", { eggsToHatch: eggsToHatchCount }), 0); this.scene.ui.setModeWithoutClear(Mode.CONFIRM, () => { this.hatchEggsSkipped(eggsToHatch); this.showSummary(); diff --git a/src/system/settings/settings.ts b/src/system/settings/settings.ts index d71edf603dd..64ddfdae5cf 100644 --- a/src/system/settings/settings.ts +++ b/src/system/settings/settings.ts @@ -1,9 +1,9 @@ import { Mode } from "#app/ui/ui"; import i18next from "i18next"; -import BattleScene from "../../battle-scene"; -import { hasTouchscreen } from "../../touch-controls"; -import { updateWindowType } from "../../ui/ui-theme"; -import { CandyUpgradeNotificationChangedEvent } from "../../events/battle-scene"; +import BattleScene from "#app/battle-scene"; +import { hasTouchscreen } from "#app/touch-controls"; +import { updateWindowType } from "#app/ui/ui-theme"; +import { CandyUpgradeNotificationChangedEvent } from "#app/events/battle-scene"; import SettingsUiHandler from "#app/ui/settings/settings-ui-handler"; import { EaseType } from "#enums/ease-type"; import { MoneyFormat } from "#enums/money-format"; @@ -44,6 +44,7 @@ const OFF_ON: SettingOption[] = [ label: i18next.t("settings:on") } ]; + const AUTO_DISABLED: SettingOption[] = [ { value: "Auto", @@ -55,6 +56,19 @@ const AUTO_DISABLED: SettingOption[] = [ } ]; +const TOUCH_CONTROLS_OPTIONS: SettingOption[] = [ + { + value: "Auto", + label: i18next.t("settings:auto") + }, + { + value: "Disabled", + label: i18next.t("settings:disabled"), + needConfirmation: true, + confirmationMessage: i18next.t("settings:confirmDisableTouch") + } +]; + const SHOP_CURSOR_TARGET_OPTIONS: SettingOption[] = [ { value: "Rewards", @@ -100,7 +114,9 @@ export enum SettingType { type SettingOption = { value: string, - label: string + label: string, + needConfirmation?: boolean, + confirmationMessage?: string }; export interface Setting { @@ -344,13 +360,6 @@ export const Setting: Array = [ default: 1, type: SettingType.GENERAL }, - { - key: SettingKeys.Touch_Controls, - label: i18next.t("settings:touchControls"), - options: AUTO_DISABLED, - default: 0, - type: SettingType.GENERAL - }, { key: SettingKeys.Vibration, label: i18next.t("settings:vibrations"), @@ -358,6 +367,28 @@ export const Setting: Array = [ default: 0, type: SettingType.GENERAL }, + { + key: SettingKeys.Touch_Controls, + label: i18next.t("settings:touchControls"), + options: TOUCH_CONTROLS_OPTIONS, + default: 0, + type: SettingType.GENERAL, + isHidden: () => !hasTouchscreen() + }, + { + key: SettingKeys.Move_Touch_Controls, + label: i18next.t("settings:moveTouchControls"), + options: [ + { + value: "Configure", + label: i18next.t("settings:change") + } + ], + default: 0, + type: SettingType.GENERAL, + activatable: true, + isHidden: () => !hasTouchscreen() + }, { key: SettingKeys.Language, label: i18next.t("settings:language"), @@ -643,20 +674,6 @@ export const Setting: Array = [ type: SettingType.AUDIO, requireReload: true }, - { - key: SettingKeys.Move_Touch_Controls, - label: i18next.t("settings:moveTouchControls"), - options: [ - { - value: "Configure", - label: i18next.t("settings:change") - } - ], - default: 0, - type: SettingType.GENERAL, - activatable: true, - isHidden: () => !hasTouchscreen() - }, { key: SettingKeys.Shop_Cursor_Target, label: i18next.t("settings:shopCursorTarget"), @@ -849,7 +866,7 @@ export function setSetting(scene: BattleScene, setting: string, value: integer): if (scene.ui) { const cancelHandler = () => { scene.ui.revertMode(); - (scene.ui.getHandler() as SettingsUiHandler).setOptionCursor(0, 0, true); + (scene.ui.getHandler() as SettingsUiHandler).setOptionCursor(-1, 0, true); }; const changeLocaleHandler = (locale: string): boolean => { try { diff --git a/src/test/items/multi_lens.test.ts b/src/test/items/multi_lens.test.ts index c5e60c9f9e5..f4b4c5712ee 100644 --- a/src/test/items/multi_lens.test.ts +++ b/src/test/items/multi_lens.test.ts @@ -135,4 +135,57 @@ describe("Items - Multi Lens", () => { expect(damageResults[0]).toBe(Math.floor(playerPokemon.level * 0.75)); expect(damageResults[1]).toBe(Math.floor(playerPokemon.level * 0.25)); }); + + it("should result in correct damage for hp% attacks with 1 lens", async () => { + game.override.startingHeldItems([{ name: "MULTI_LENS", count: 1 }]) + .moveset(Moves.SUPER_FANG) + .ability(Abilities.COMPOUND_EYES) + .enemyLevel(1000) + .enemySpecies(Species.BLISSEY); // allows for unrealistically high levels of accuracy + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.SUPER_FANG); + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + await game.phaseInterceptor.to("MoveEndPhase"); + expect(enemyPokemon.getHpRatio()).toBeCloseTo(0.5, 5); + }); + + it("should result in correct damage for hp% attacks with 2 lenses", async () => { + game.override.startingHeldItems([{ name: "MULTI_LENS", count: 2 }]) + .moveset(Moves.SUPER_FANG) + .ability(Abilities.COMPOUND_EYES) + .enemyMoveset(Moves.SPLASH) + .enemyLevel(1000) + .enemySpecies(Species.BLISSEY); // allows for unrealistically high levels of accuracy + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.SUPER_FANG); + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + await game.phaseInterceptor.to("MoveEndPhase"); + expect(enemyPokemon.getHpRatio()).toBeCloseTo(0.5, 5); + }); + it("should result in correct damage for hp% attacks with 2 lenses + Parental Bond", async () => { + game.override.startingHeldItems([{ name: "MULTI_LENS", count: 2 }]) + .moveset(Moves.SUPER_FANG) + .ability(Abilities.PARENTAL_BOND) + .passiveAbility(Abilities.COMPOUND_EYES) + .enemyMoveset(Moves.SPLASH) + .enemyLevel(1000) + .enemySpecies(Species.BLISSEY); // allows for unrealistically high levels of accuracy + + await game.classicMode.startBattle([ Species.MAGIKARP ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + + game.move.select(Moves.SUPER_FANG); + await game.setTurnOrder([ BattlerIndex.PLAYER, BattlerIndex.ENEMY ]); + await game.phaseInterceptor.to("MoveEndPhase"); + expect(enemyPokemon.getHpRatio()).toBeCloseTo(0.25, 5); + }); }); diff --git a/src/test/moves/dragon_tail.test.ts b/src/test/moves/dragon_tail.test.ts index 6b3e669f770..96db67279d3 100644 --- a/src/test/moves/dragon_tail.test.ts +++ b/src/test/moves/dragon_tail.test.ts @@ -1,5 +1,9 @@ import { BattlerIndex } from "#app/battle"; import { allMoves } from "#app/data/move"; +import { Status } from "#app/data/status-effect"; +import { Challenges } from "#enums/challenges"; +import { StatusEffect } from "#enums/status-effect"; +import { Type } from "#enums/type"; import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; @@ -193,4 +197,122 @@ describe("Moves - Dragon Tail", () => { expect(dratini.hp).toBe(Math.floor(dratini.getMaxHp() / 2)); expect(game.scene.getPlayerField().length).toBe(1); }); + + it("should force switches randomly", async () => { + game.override.enemyMoveset(Moves.DRAGON_TAIL) + .startingLevel(100) + .enemyLevel(1); + await game.classicMode.startBattle([ Species.BULBASAUR, Species.CHARMANDER, Species.SQUIRTLE ]); + + const [ bulbasaur, charmander, squirtle ] = game.scene.getPlayerParty(); + + // Turn 1: Mock an RNG call that calls for switching to 1st backup Pokemon (Charmander) + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((range, min: number = 0) => { + return min; + }); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.DRAGON_TAIL); + await game.toNextTurn(); + + expect(bulbasaur.isOnField()).toBe(false); + expect(charmander.isOnField()).toBe(true); + expect(squirtle.isOnField()).toBe(false); + expect(bulbasaur.getInverseHp()).toBeGreaterThan(0); + + // Turn 2: Mock an RNG call that calls for switching to 2nd backup Pokemon (Squirtle) + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((range, min: number = 0) => { + return min + 1; + }); + game.move.select(Moves.SPLASH); + await game.toNextTurn(); + + expect(bulbasaur.isOnField()).toBe(false); + expect(charmander.isOnField()).toBe(false); + expect(squirtle.isOnField()).toBe(true); + expect(charmander.getInverseHp()).toBeGreaterThan(0); + }); + + it("should not force a switch to a challenge-ineligible Pokemon", async () => { + game.override.enemyMoveset(Moves.DRAGON_TAIL) + .startingLevel(100) + .enemyLevel(1); + // Mono-Water challenge, Eevee is ineligible + game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, Type.WATER + 1, 0); + await game.challengeMode.startBattle([ Species.LAPRAS, Species.EEVEE, Species.TOXAPEX, Species.PRIMARINA ]); + + const [ lapras, eevee, toxapex, primarina ] = game.scene.getPlayerParty(); + + // Turn 1: Mock an RNG call that would normally call for switching to Eevee, but it is ineligible + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((range, min: number = 0) => { + return min; + }); + game.move.select(Moves.SPLASH); + await game.toNextTurn(); + + expect(lapras.isOnField()).toBe(false); + expect(eevee.isOnField()).toBe(false); + expect(toxapex.isOnField()).toBe(true); + expect(primarina.isOnField()).toBe(false); + expect(lapras.getInverseHp()).toBeGreaterThan(0); + }); + + it("should not force a switch to a fainted Pokemon", async () => { + game.override.enemyMoveset([ Moves.SPLASH, Moves.DRAGON_TAIL ]) + .startingLevel(100) + .enemyLevel(1); + await game.classicMode.startBattle([ Species.LAPRAS, Species.EEVEE, Species.TOXAPEX, Species.PRIMARINA ]); + + const [ lapras, eevee, toxapex, primarina ] = game.scene.getPlayerParty(); + + // Turn 1: Eevee faints + eevee.hp = 0; + eevee.status = new Status(StatusEffect.FAINT); + expect(eevee.isFainted()).toBe(true); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.SPLASH); + await game.toNextTurn(); + + // Turn 2: Mock an RNG call that would normally call for switching to Eevee, but it is fainted + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((range, min: number = 0) => { + return min; + }); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.DRAGON_TAIL); + await game.toNextTurn(); + + expect(lapras.isOnField()).toBe(false); + expect(eevee.isOnField()).toBe(false); + expect(toxapex.isOnField()).toBe(true); + expect(primarina.isOnField()).toBe(false); + expect(lapras.getInverseHp()).toBeGreaterThan(0); + }); + + it("should not force a switch if there are no available Pokemon to switch into", async () => { + game.override.enemyMoveset([ Moves.SPLASH, Moves.DRAGON_TAIL ]) + .startingLevel(100) + .enemyLevel(1); + await game.classicMode.startBattle([ Species.LAPRAS, Species.EEVEE ]); + + const [ lapras, eevee ] = game.scene.getPlayerParty(); + + // Turn 1: Eevee faints + eevee.hp = 0; + eevee.status = new Status(StatusEffect.FAINT); + expect(eevee.isFainted()).toBe(true); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.SPLASH); + await game.toNextTurn(); + + // Turn 2: Mock an RNG call that would normally call for switching to Eevee, but it is fainted + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((range, min: number = 0) => { + return min; + }); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.DRAGON_TAIL); + await game.toNextTurn(); + + expect(lapras.isOnField()).toBe(true); + expect(eevee.isOnField()).toBe(false); + expect(lapras.getInverseHp()).toBeGreaterThan(0); + }); }); diff --git a/src/test/moves/shell_side_arm.test.ts b/src/test/moves/shell_side_arm.test.ts index 9646d27f17e..41cbefb186b 100644 --- a/src/test/moves/shell_side_arm.test.ts +++ b/src/test/moves/shell_side_arm.test.ts @@ -26,7 +26,7 @@ describe("Moves - Shell Side Arm", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset([ Moves.SHELL_SIDE_ARM ]) + .moveset([ Moves.SHELL_SIDE_ARM, Moves.SPLASH ]) .battleType("single") .startingLevel(100) .enemyLevel(100) @@ -69,6 +69,9 @@ describe("Moves - Shell Side Arm", () => { vi.spyOn(shellSideArmAttr, "apply"); + game.move.select(Moves.SPLASH); + await game.toNextTurn(); + game.move.select(Moves.SHELL_SIDE_ARM); await game.setTurnOrder([ BattlerIndex.ENEMY, BattlerIndex.PLAYER ]); await game.phaseInterceptor.to("BerryPhase", false); diff --git a/src/test/moves/whirlwind.test.ts b/src/test/moves/whirlwind.test.ts index c16f38111f2..69232bee43a 100644 --- a/src/test/moves/whirlwind.test.ts +++ b/src/test/moves/whirlwind.test.ts @@ -1,11 +1,15 @@ -import { BattlerTagType } from "#app/enums/battler-tag-type"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { Challenges } from "#enums/challenges"; +import { Type } from "#enums/type"; import { MoveResult } from "#app/field/pokemon"; import { Abilities } from "#enums/abilities"; import { Moves } from "#enums/moves"; import { Species } from "#enums/species"; import GameManager from "#test/utils/gameManager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { Status } from "#app/data/status-effect"; +import { StatusEffect } from "#enums/status-effect"; describe("Moves - Whirlwind", () => { let phaserGame: Phaser.Game; @@ -25,8 +29,9 @@ describe("Moves - Whirlwind", () => { game = new GameManager(phaserGame); game.override .battleType("single") + .moveset(Moves.SPLASH) .enemyAbility(Abilities.BALL_FETCH) - .enemyMoveset(Moves.WHIRLWIND) + .enemyMoveset([ Moves.SPLASH, Moves.WHIRLWIND ]) .enemySpecies(Species.PIDGEY); }); @@ -41,10 +46,114 @@ describe("Moves - Whirlwind", () => { const staraptor = game.scene.getPlayerPokemon()!; game.move.select(move); + await game.forceEnemyMove(Moves.WHIRLWIND); await game.phaseInterceptor.to("BerryPhase", false); expect(staraptor.findTag((t) => t.tagType === BattlerTagType.FLYING)).toBeDefined(); expect(game.scene.getEnemyPokemon()!.getLastXMoves(1)[0].result).toBe(MoveResult.MISS); }); + + it("should force switches randomly", async () => { + await game.classicMode.startBattle([ Species.BULBASAUR, Species.CHARMANDER, Species.SQUIRTLE ]); + + const [ bulbasaur, charmander, squirtle ] = game.scene.getPlayerParty(); + + // Turn 1: Mock an RNG call that calls for switching to 1st backup Pokemon (Charmander) + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((range, min: number = 0) => { + return min; + }); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.WHIRLWIND); + await game.toNextTurn(); + + expect(bulbasaur.isOnField()).toBe(false); + expect(charmander.isOnField()).toBe(true); + expect(squirtle.isOnField()).toBe(false); + + // Turn 2: Mock an RNG call that calls for switching to 2nd backup Pokemon (Squirtle) + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((range, min: number = 0) => { + return min + 1; + }); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.WHIRLWIND); + await game.toNextTurn(); + + expect(bulbasaur.isOnField()).toBe(false); + expect(charmander.isOnField()).toBe(false); + expect(squirtle.isOnField()).toBe(true); + }); + + it("should not force a switch to a challenge-ineligible Pokemon", async () => { + // Mono-Water challenge, Eevee is ineligible + game.challengeMode.addChallenge(Challenges.SINGLE_TYPE, Type.WATER + 1, 0); + await game.challengeMode.startBattle([ Species.LAPRAS, Species.EEVEE, Species.TOXAPEX, Species.PRIMARINA ]); + + const [ lapras, eevee, toxapex, primarina ] = game.scene.getPlayerParty(); + + // Turn 1: Mock an RNG call that would normally call for switching to Eevee, but it is ineligible + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((range, min: number = 0) => { + return min; + }); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.WHIRLWIND); + await game.toNextTurn(); + + expect(lapras.isOnField()).toBe(false); + expect(eevee.isOnField()).toBe(false); + expect(toxapex.isOnField()).toBe(true); + expect(primarina.isOnField()).toBe(false); + }); + + it("should not force a switch to a fainted Pokemon", async () => { + await game.classicMode.startBattle([ Species.LAPRAS, Species.EEVEE, Species.TOXAPEX, Species.PRIMARINA ]); + + const [ lapras, eevee, toxapex, primarina ] = game.scene.getPlayerParty(); + + // Turn 1: Eevee faints + eevee.hp = 0; + eevee.status = new Status(StatusEffect.FAINT); + expect(eevee.isFainted()).toBe(true); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.SPLASH); + await game.toNextTurn(); + + // Turn 2: Mock an RNG call that would normally call for switching to Eevee, but it is fainted + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((range, min: number = 0) => { + return min; + }); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.WHIRLWIND); + await game.toNextTurn(); + + expect(lapras.isOnField()).toBe(false); + expect(eevee.isOnField()).toBe(false); + expect(toxapex.isOnField()).toBe(true); + expect(primarina.isOnField()).toBe(false); + }); + + it("should not force a switch if there are no available Pokemon to switch into", async () => { + await game.classicMode.startBattle([ Species.LAPRAS, Species.EEVEE ]); + + const [ lapras, eevee ] = game.scene.getPlayerParty(); + + // Turn 1: Eevee faints + eevee.hp = 0; + eevee.status = new Status(StatusEffect.FAINT); + expect(eevee.isFainted()).toBe(true); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.SPLASH); + await game.toNextTurn(); + + // Turn 2: Mock an RNG call that would normally call for switching to Eevee, but it is fainted + vi.spyOn(game.scene, "randBattleSeedInt").mockImplementation((range, min: number = 0) => { + return min; + }); + game.move.select(Moves.SPLASH); + await game.forceEnemyMove(Moves.WHIRLWIND); + await game.toNextTurn(); + + expect(lapras.isOnField()).toBe(true); + expect(eevee.isOnField()).toBe(false); + }); }); diff --git a/src/test/mystery-encounter/encounters/safari-zone.test.ts b/src/test/mystery-encounter/encounters/safari-zone.test.ts new file mode 100644 index 00000000000..5699afe254f --- /dev/null +++ b/src/test/mystery-encounter/encounters/safari-zone.test.ts @@ -0,0 +1,173 @@ +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import { Biome } from "#enums/biome"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import { Species } from "#enums/species"; +import GameManager from "#test/utils/gameManager"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption } from "#test/mystery-encounter/encounter-test-utils"; +import BattleScene from "#app/battle-scene"; +import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; +import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; +import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; +import MysteryEncounter from "#app/data/mystery-encounters/mystery-encounter"; +import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { getSafariSpeciesSpawn, SafariZoneEncounter } from "#app/data/mystery-encounters/encounters/safari-zone-encounter"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { NON_LEGEND_PARADOX_POKEMON } from "#app/data/balance/special-species-groups"; + +const namespace = "mysteryEncounters/safariZone"; +const defaultParty = [ Species.LAPRAS, Species.GENGAR, Species.ABRA ]; +const defaultBiome = Biome.SWAMP; +const defaultWave = 45; + +describe("Safari Zone - 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(); + + const biomeMap = new Map([ + [ Biome.VOLCANO, [ MysteryEncounterType.FIGHT_OR_FLIGHT ]], + [ Biome.FOREST, [ MysteryEncounterType.SAFARI_ZONE ]], + [ Biome.SWAMP, [ MysteryEncounterType.SAFARI_ZONE ]], + [ Biome.JUNGLE, [ MysteryEncounterType.SAFARI_ZONE ]], + ]); + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue(biomeMap); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + it("should have the correct properties", async () => { + await game.runToMysteryEncounter(MysteryEncounterType.SAFARI_ZONE, defaultParty); + + expect(SafariZoneEncounter.encounterType).toBe(MysteryEncounterType.SAFARI_ZONE); + expect(SafariZoneEncounter.encounterTier).toBe(MysteryEncounterTier.GREAT); + expect(SafariZoneEncounter.dialogue).toBeDefined(); + expect(SafariZoneEncounter.dialogue.intro).toStrictEqual([ + { text: `${namespace}:intro` }, + ]); + expect(SafariZoneEncounter.dialogue.encounterOptionsDialogue?.title).toBe(`${namespace}:title`); + expect(SafariZoneEncounter.dialogue.encounterOptionsDialogue?.description).toBe(`${namespace}:description`); + expect(SafariZoneEncounter.dialogue.encounterOptionsDialogue?.query).toBe(`${namespace}:query`); + expect(SafariZoneEncounter.options.length).toBe(2); + }); + + it("should not spawn outside of the forest, swamp, or jungle biomes", async () => { + game.override.mysteryEncounterTier(MysteryEncounterTier.GREAT); + game.override.startingBiome(Biome.VOLCANO); + await game.runToMysteryEncounter(); + + expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.SAFARI_ZONE); + }); + + it("should initialize fully", async () => { + initSceneWithoutEncounterPhase(scene, defaultParty); + scene.currentBattle.mysteryEncounter = new MysteryEncounter(SafariZoneEncounter); + const encounter = scene.currentBattle.mysteryEncounter!; + scene.currentBattle.waveIndex = defaultWave; + + const { onInit } = encounter; + + expect(encounter.onInit).toBeDefined(); + + encounter.populateDialogueTokensFromRequirements(scene); + const onInitResult = onInit!(scene); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Enter", () => { + it("should have the correct properties", () => { + const option = SafariZoneEncounter.options[0]; + expect(option.optionMode).toBe(MysteryEncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option.dialogue).toBeDefined(); + expect(option.dialogue).toStrictEqual({ + buttonLabel: `${namespace}:option.1.label`, + buttonTooltip: `${namespace}:option.1.tooltip`, + selected: [ + { + text: `${namespace}:option.1.selected`, + }, + ], + }); + }); + + it("should NOT be selectable if the player doesn't have enough money", async () => { + game.scene.money = 0; + await game.runToMysteryEncounter(MysteryEncounterType.SAFARI_ZONE, defaultParty); + 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, 1); + + 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 not spawn any Paradox Pokemon", async () => { + const NUM_ROLLS = 2000; // As long as this is greater than total number of species, this should cover all possible RNG rolls + let rngSweepProgress = 0; // Will simulate full range of RNG rolls by steadily increasing from 0 to 1 + + vi.spyOn(Phaser.Math.RND, "realInRange").mockImplementation((min: number, max: number) => { + return rngSweepProgress * (max - min) + min; + }); + vi.spyOn(Phaser.Math.RND, "shuffle").mockImplementation((arr: any[]) => arr); + + for (let i = 0; i < NUM_ROLLS; i++) { + rngSweepProgress = (2 * i + 1) / (2 * NUM_ROLLS); + const simSpecies = getSafariSpeciesSpawn().speciesId; + expect(NON_LEGEND_PARADOX_POKEMON).not.toContain(simSpecies); + } + }); + + // TODO: Tests for player actions inside the Safari Zone (Pokeball, Mud, Bait, Flee) + }); + + describe("Option 2 - Leave", () => { + it("should have the correct properties", () => { + const option = SafariZoneEncounter.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: [ + { + text: `${namespace}:option.2.selected`, + }, + ], + }); + }); + + it("should leave encounter without battle", async () => { + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await game.runToMysteryEncounter(MysteryEncounterType.SAFARI_ZONE, defaultParty); + await runMysteryEncounterToEnd(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/mystery-encounter/encounters/the-pokemon-salesman-encounter.test.ts b/src/test/mystery-encounter/encounters/the-pokemon-salesman-encounter.test.ts index a50c0cf4c9e..91b4a4bcab5 100644 --- a/src/test/mystery-encounter/encounters/the-pokemon-salesman-encounter.test.ts +++ b/src/test/mystery-encounter/encounters/the-pokemon-salesman-encounter.test.ts @@ -9,11 +9,12 @@ import { runMysteryEncounterToEnd, runSelectMysteryEncounterOption } from "#test import BattleScene from "#app/battle-scene"; import { PlayerPokemon } from "#app/field/pokemon"; import { HUMAN_TRANSITABLE_BIOMES } from "#app/data/mystery-encounters/mystery-encounters"; -import { ThePokemonSalesmanEncounter } from "#app/data/mystery-encounters/encounters/the-pokemon-salesman-encounter"; +import { getSalesmanSpeciesOffer, ThePokemonSalesmanEncounter } from "#app/data/mystery-encounters/encounters/the-pokemon-salesman-encounter"; import { MysteryEncounterOptionMode } from "#enums/mystery-encounter-option-mode"; import { MysteryEncounterTier } from "#enums/mystery-encounter-tier"; import { initSceneWithoutEncounterPhase } from "#test/utils/gameManagerUtils"; import { MysteryEncounterPhase } from "#app/phases/mystery-encounter-phases"; +import { NON_LEGEND_PARADOX_POKEMON } from "#app/data/balance/special-species-groups"; const namespace = "mysteryEncounters/thePokemonSalesman"; const defaultParty = [ Species.LAPRAS, Species.GENGAR, Species.ABRA ]; @@ -172,6 +173,22 @@ describe("The Pokemon Salesman - Mystery Encounter", () => { expect(mysteryEncounterPhase.continueEncounter).not.toHaveBeenCalled(); }); + it("should not offer any Paradox Pokemon", async () => { + const NUM_ROLLS = 2000; // As long as this is greater than total number of species, this should cover all possible RNG rolls + let rngSweepProgress = 0; // Will simulate full range of RNG rolls by steadily increasing from 0 to 1 + + vi.spyOn(Phaser.Math.RND, "realInRange").mockImplementation((min: number, max: number) => { + return rngSweepProgress * (max - min) + min; + }); + vi.spyOn(Phaser.Math.RND, "shuffle").mockImplementation((arr: any[]) => arr); + + for (let i = 0; i < NUM_ROLLS; i++) { + rngSweepProgress = (2 * i + 1) / (2 * NUM_ROLLS); + const simSpecies = getSalesmanSpeciesOffer().speciesId; + expect(NON_LEGEND_PARADOX_POKEMON).not.toContain(simSpecies); + } + }); + it("should leave encounter without battle", async () => { scene.money = 20000; const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); diff --git a/src/test/mystery-encounter/mystery-encounter-utils.test.ts b/src/test/mystery-encounter/mystery-encounter-utils.test.ts index ab2d5f052c0..d17b5c8a587 100644 --- a/src/test/mystery-encounter/mystery-encounter-utils.test.ts +++ b/src/test/mystery-encounter/mystery-encounter-utils.test.ts @@ -2,7 +2,7 @@ import BattleScene from "#app/battle-scene"; import { speciesStarterCosts } from "#app/data/balance/starters"; import MysteryEncounter from "#app/data/mystery-encounters/mystery-encounter"; import { getEncounterText, queueEncounterMessage, showEncounterDialogue, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; -import { getHighestLevelPlayerPokemon, getLowestLevelPlayerPokemon, getRandomPlayerPokemon, getRandomSpeciesByStarterTier, koPlayerPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; +import { getHighestLevelPlayerPokemon, getLowestLevelPlayerPokemon, getRandomPlayerPokemon, getRandomSpeciesByStarterCost, koPlayerPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; import { getPokemonSpecies } from "#app/data/pokemon-species"; import { Type } from "#enums/type"; import { MessagePhase } from "#app/phases/message-phase"; @@ -204,9 +204,9 @@ describe("Mystery Encounter Utils", () => { }); }); - describe("getRandomSpeciesByStarterTier", () => { + describe("getRandomSpeciesByStarterCost", () => { it("gets species for a starter tier", () => { - const result = getRandomSpeciesByStarterTier(5); + const result = getRandomSpeciesByStarterCost(5); const pokeSpecies = getPokemonSpecies(result); expect(pokeSpecies.speciesId).toBe(result); @@ -214,7 +214,7 @@ describe("Mystery Encounter Utils", () => { }); it("gets species for a starter tier range", () => { - const result = getRandomSpeciesByStarterTier([ 5, 8 ]); + const result = getRandomSpeciesByStarterCost([ 5, 8 ]); const pokeSpecies = getPokemonSpecies(result); expect(pokeSpecies.speciesId).toBe(result); @@ -224,14 +224,14 @@ describe("Mystery Encounter Utils", () => { it("excludes species from search", () => { // Only 9 tiers are: Koraidon, Miraidon, Arceus, Rayquaza, Kyogre, Groudon, Zacian - const result = getRandomSpeciesByStarterTier(9, [ Species.KORAIDON, Species.MIRAIDON, Species.ARCEUS, Species.RAYQUAZA, Species.KYOGRE, Species.GROUDON ]); + const result = getRandomSpeciesByStarterCost(9, [ Species.KORAIDON, Species.MIRAIDON, Species.ARCEUS, Species.RAYQUAZA, Species.KYOGRE, Species.GROUDON ]); const pokeSpecies = getPokemonSpecies(result); expect(pokeSpecies.speciesId).toBe(Species.ZACIAN); }); it("gets species of specified types", () => { // Only 9 tiers are: Koraidon, Miraidon, Arceus, Rayquaza, Kyogre, Groudon, Zacian - const result = getRandomSpeciesByStarterTier(9, undefined, [ Type.GROUND ]); + const result = getRandomSpeciesByStarterCost(9, undefined, [ Type.GROUND ]); const pokeSpecies = getPokemonSpecies(result); expect(pokeSpecies.speciesId).toBe(Species.GROUDON); }); diff --git a/src/ui/settings/abstract-settings-ui-handler.ts b/src/ui/settings/abstract-settings-ui-handler.ts index 83219e1ef5a..cbc93887810 100644 --- a/src/ui/settings/abstract-settings-ui-handler.ts +++ b/src/ui/settings/abstract-settings-ui-handler.ts @@ -1,8 +1,7 @@ import BattleScene from "#app/battle-scene"; -import { hasTouchscreen, isMobile } from "#app/touch-controls"; import { TextStyle, addTextObject } from "#app/ui/text"; import { Mode } from "#app/ui/ui"; -import UiHandler from "#app/ui/ui-handler"; +import MessageUiHandler from "#app/ui/message-ui-handler"; import { addWindow } from "#app/ui/ui-theme"; import { ScrollBar } from "#app/ui/scroll-bar"; import { Button } from "#enums/buttons"; @@ -15,9 +14,10 @@ import i18next from "i18next"; /** * Abstract class for handling UI elements related to settings. */ -export default class AbstractSettingsUiHandler extends UiHandler { +export default class AbstractSettingsUiHandler extends MessageUiHandler { private settingsContainer: Phaser.GameObjects.Container; private optionsContainer: Phaser.GameObjects.Container; + private messageBoxContainer: Phaser.GameObjects.Container; private navigationContainer: NavigationMenu; private scrollCursor: number; @@ -135,6 +135,23 @@ export default class AbstractSettingsUiHandler extends UiHandler { this.scrollBar = new ScrollBar(this.scene, this.optionsBg.width - 9, this.optionsBg.y + 5, 4, this.optionsBg.height - 11, this.rowsToDisplay); this.scrollBar.setTotalRows(this.settings.length); + // Two-lines message box + this.messageBoxContainer = this.scene.add.container(0, this.scene.scaledCanvas.height); + this.messageBoxContainer.setName("settings-message-box"); + this.messageBoxContainer.setVisible(false); + + const settingsMessageBox = addWindow(this.scene, 0, -1, this.scene.scaledCanvas.width - 2, 48); + settingsMessageBox.setOrigin(0, 1); + this.messageBoxContainer.add(settingsMessageBox); + + const messageText = addTextObject(this.scene, 8, -40, "", TextStyle.WINDOW, { maxLines: 2 }); + messageText.setWordWrapWidth(this.scene.game.canvas.width - 60); + messageText.setName("settings-message"); + messageText.setOrigin(0, 0); + + this.messageBoxContainer.add(messageText); + this.message = messageText; + this.settingsContainer.add(this.optionsBg); this.settingsContainer.add(this.scrollBar); this.settingsContainer.add(this.navigationContainer); @@ -144,6 +161,7 @@ export default class AbstractSettingsUiHandler extends UiHandler { this.settingsContainer.add(iconCancel); this.settingsContainer.add(actionText); this.settingsContainer.add(cancelText); + this.settingsContainer.add(this.messageBoxContainer); ui.add(this.settingsContainer); @@ -326,18 +344,16 @@ export default class AbstractSettingsUiHandler extends UiHandler { /** * Set the option cursor to the specified position. * - * @param settingIndex - The index of the setting. + * @param settingIndex - The index of the setting or -1 to change the current setting * @param cursor - The cursor position to set. * @param save - Whether to save the setting to local storage. * @returns `true` if the option cursor was set successfully. */ setOptionCursor(settingIndex: number, cursor: number, save?: boolean): boolean { - const setting = this.settings[settingIndex]; - - if (setting.key === SettingKeys.Touch_Controls && cursor && hasTouchscreen() && isMobile()) { - this.getUi().playError(); - return false; + if (settingIndex === -1) { + settingIndex = this.cursor + this.scrollCursor; } + const setting = this.settings[settingIndex]; const lastCursor = this.optionCursors[settingIndex]; @@ -352,9 +368,33 @@ export default class AbstractSettingsUiHandler extends UiHandler { newValueLabel.setShadowColor(this.getTextColor(TextStyle.SETTINGS_SELECTED, true)); if (save) { - this.scene.gameData.saveSetting(setting.key, cursor); - if (this.reloadSettings.includes(setting)) { - this.reloadRequired = true; + const saveSetting = () => { + this.scene.gameData.saveSetting(setting.key, cursor); + if (setting.requireReload) { + this.reloadRequired = true; + } + }; + + // For settings that ask for confirmation, display confirmation message and a Yes/No prompt before saving the setting + if (setting.options[cursor].needConfirmation) { + const confirmUpdateSetting = () => { + this.scene.ui.revertMode(); + this.showText(""); + saveSetting(); + }; + const cancelUpdateSetting = () => { + this.scene.ui.revertMode(); + this.showText(""); + // Put the cursor back to its previous position without saving or asking for confirmation again + this.setOptionCursor(settingIndex, lastCursor, false); + }; + + const confirmationMessage = setting.options[cursor].confirmationMessage ?? i18next.t("settings:defaultConfirmMessage"); + this.scene.ui.showText(confirmationMessage, null, () => { + this.scene.ui.setOverlayMode(Mode.CONFIRM, confirmUpdateSetting, cancelUpdateSetting, null, null, 1, 750); + }); + } else { + saveSetting(); } } @@ -421,4 +461,9 @@ export default class AbstractSettingsUiHandler extends UiHandler { } this.cursorObj = null; } + + override showText(text: string, delay?: integer, callback?: Function, callbackDelay?: integer, prompt?: boolean, promptDelay?: integer) { + this.messageBoxContainer.setVisible(!!text?.length); + super.showText(text, delay, callback, callbackDelay, prompt, promptDelay); + } }