diff --git a/.github/ISSUE_TEMPLATE/mystery_event.yml b/.github/ISSUE_TEMPLATE/mystery_event.yml index be7b98eda78..3791026e778 100644 --- a/.github/ISSUE_TEMPLATE/mystery_event.yml +++ b/.github/ISSUE_TEMPLATE/mystery_event.yml @@ -9,7 +9,7 @@ body: attributes: label: Event Name description: Name of the event - placeholder: e.g Fight or Flight + placeholder: e.g. "Fight or Flight" validations: required: true - type: markdown # SEPARATOR @@ -20,15 +20,24 @@ body: id: rarity attributes: label: Rarity Tier + description: Check out the [Event Proposal Guide](https://github.com/AsdarDevelops/PokeRogue-Events/blob/mystery-battle-events/MEs_Proposal_Guide.md) if you have not yet! multiple: false options: - Common - Great - Ultra - Rogue + - Part of a "Quest" - Other or unsure (please specify) + + - type: input + id: rarity-other + attributes: + label: Rarity Tier - Other. Please Specify + description: If you chose `Other` on the `Rarity Tier` please specify it here + placeholder: e.g. "I'm unsure of whether this should be Common or Great" validations: - required: true + required: false - type: markdown # SEPARATOR attributes: value: | @@ -37,8 +46,8 @@ body: id: waves attributes: label: Waves - description: Classic/Challenge is 1 -200. Currently only 11-179 is supported. - placeholder: 1-200 + description: Classic/Challenge ranges 1-200. Currently only 11-179 is supported. + placeholder: 11-179 validations: required: true - type: markdown # SEPARATOR @@ -49,8 +58,8 @@ body: id: description attributes: label: Description - description: Describe the event you are proposing - placeholder: What is it? + description: Describe the event you are proposing. Explain its theme and how it's different from others. If the Event has any requirements to even trigger, detail them here too. + placeholder: e.g. "Fight or Flight is a common event where the player can fight a boss PKMN of the biome. The PKMN is stronger than usual, but also holds an item that's better than usual." validations: required: true - type: markdown # SEPARATOR @@ -61,11 +70,13 @@ body: id: biomes attributes: label: Biomes - description: Select all biomes where the event can occur + description: Select all biomes where the event can occur. "ANY, NON-EXTREME, CIVILIZATION and HUMAN are groups of biomes. Check the [Biomes part of the guide](https://github.com/AsdarDevelops/PokeRogue-Events/blob/mystery-battle-events/MEs_Proposal_Guide.md#biomes)." multiple: true options: - - ANY (no need to select all) - - NON-EXTREME (almost all except Space, Seabed, etc...) + - ANY + - NON-EXTREME + - HUMAN + - CIVILIZATION - TOWN - PLAINS - GRASS @@ -104,6 +115,15 @@ body: - OTHER (please specify) validations: required: true + + - type: input + id: biome-other + attributes: + label: Biome - Other. Please Specify + description: If you chose `Other` on the `Biome` please specify it here + placeholder: e.g. "I would like to only trigger at Graveyard at night!" + validations: + required: false - type: markdown # SEPARATOR attributes: value: | @@ -134,13 +154,25 @@ body: attributes: label: Explanation/Notes on Design description: Explain why you think this design is right and what this Event brings to the table - placeholder: Explain why you think this design is right and what this Event brings to the table + placeholder: e.g. "We need more simple Events that mix slightly higher stakes with slightly better rewards" validations: required: true - type: markdown # SEPARATOR attributes: value: | --- + - type: textarea + id: artist-notes + attributes: + label: Notes to Artists + description: Does your Event need custom spriting? If so, please detail them here (reference screenshots are helpful) + placeholder: Ie. "We currently don't have a Cynthia sprite while dressed in a Garchomp costume. RAWR! This is highly needed for my Event!" + validations: + required: false + - type: markdown # SEPARATOR + attributes: + value: | + --- - type: textarea id: dev-notes attributes: diff --git a/.github/workflows/mystery-event.yml b/.github/workflows/mystery-event.yml new file mode 100644 index 00000000000..016ea7abdba --- /dev/null +++ b/.github/workflows/mystery-event.yml @@ -0,0 +1,53 @@ +# These are workflows use exclusively for the mystery events sub-project +# It's basically a copy of eslint.yml & test.yml just aimed at a different branch + +name: Mystery Events workflows + +on: + # Trigger the workflow on push or pull request, + # but only for the mystery-battle-events branch + push: + branches: + - mystery-battle-events # Trigger on push events to the mystery-battle-events branch + pull_request: + branches: + - mystery-battle-events # Trigger on pull request events targeting the mystery-battle-events branch + +jobs: + run-linters: # Define a job named "run-linters" + name: Run linters # Human-readable name for the job + runs-on: ubuntu-latest # Specify the latest Ubuntu runner for the job + + steps: + - name: Check out Git repository # Step to check out the repository + uses: actions/checkout@v2 # Use the checkout action version 2 + + - name: Set up Node.js # Step to set up Node.js environment + uses: actions/setup-node@v1 # Use the setup-node action version 1 + with: + node-version: 20 # Specify Node.js version 20 + + - name: Install Node.js dependencies # Step to install Node.js dependencies + run: npm ci # Use 'npm ci' to install dependencies + + - name: eslint # Step to run linters + run: npm run eslint-ci + + run-tests: # Define a job named "run-tests" + name: Run tests # Human-readable name for the job + runs-on: ubuntu-latest # Specify the latest Ubuntu runner for the job + + steps: + - name: Check out Git repository # Step to check out the repository + uses: actions/checkout@v4 # Use the checkout action version 4 + + - name: Set up Node.js # Step to set up Node.js environment + uses: actions/setup-node@v4 # Use the setup-node action version 4 + with: + node-version: 20 # Specify Node.js version 20 + + - name: Install Node.js dependencies # Step to install Node.js dependencies + run: npm ci # Use 'npm ci' to install dependencies + + - name: tests # Step to run tests + run: npm run test:silent diff --git a/public/images/mystery-encounters/buoy.json b/public/images/mystery-encounters/buoy.json new file mode 100644 index 00000000000..ba5d9567fe5 --- /dev/null +++ b/public/images/mystery-encounters/buoy.json @@ -0,0 +1,19 @@ +{ "frames": [ + { + "filename": "0001.png", + "frame": { "x": 0, "y": 0, "w": 46, "h": 60 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 46, "h": 60 }, + "sourceSize": { "w": 46, "h": 60 } + } + ], + "meta": { + "app": "https://www.aseprite.org/", + "version": "1.3.7-x64", + "image": "buoy-sheet.png", + "format": "RGBA8888", + "size": { "w": 46, "h": 60 }, + "scale": "1" + } +} diff --git a/public/images/mystery-encounters/buoy.png b/public/images/mystery-encounters/buoy.png new file mode 100644 index 00000000000..dee71943650 Binary files /dev/null and b/public/images/mystery-encounters/buoy.png differ diff --git a/src/battle-scene.ts b/src/battle-scene.ts index c0a3f0b090a..6cde5929308 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -2678,7 +2678,7 @@ export default class BattleScene extends SceneBase { let availableEncounters: IMysteryEncounter[] = []; // New encounter will never be the same as the most recent encounter const previousEncounter = this.mysteryEncounterData.encounteredEvents?.length > 0 ? this.mysteryEncounterData.encounteredEvents[this.mysteryEncounterData.encounteredEvents.length - 1][0] : null; - const biomeMysteryEncounters = mysteryEncountersByBiome.get(this.arena.biomeType); + const biomeMysteryEncounters = mysteryEncountersByBiome.get(this.arena.biomeType) ?? []; // If no valid encounters exist at tier, checks next tier down, continuing until there are some encounters available while (availableEncounters.length === 0 && tier >= 0) { availableEncounters = biomeMysteryEncounters diff --git a/src/data/mystery-encounters/encounters/lost-at-sea-encounter.ts b/src/data/mystery-encounters/encounters/lost-at-sea-encounter.ts new file mode 100644 index 00000000000..9e25e003e1b --- /dev/null +++ b/src/data/mystery-encounters/encounters/lost-at-sea-encounter.ts @@ -0,0 +1,139 @@ +import { getPokemonSpecies } from "#app/data/pokemon-species.js"; +import { Moves } from "#app/enums/moves"; +import { Species } from "#app/enums/species.js"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; +import BattleScene from "../../../battle-scene"; +import MysteryEncounter, { MysteryEncounterBuilder, MysteryEncounterTier } from "../mystery-encounter"; +import { EncounterOptionMode, MysteryEncounterOptionBuilder } from "../mystery-encounter-option"; +import { applyDamageToPokemon, leaveEncounterWithoutBattle, setEncounterExp } from "../utils/encounter-phase-utils"; + +const OPTION_1_REQUIRED_MOVE = Moves.SURF; +const OPTION_2_REQUIRED_MOVE = Moves.FLY; +/** + * Damage percentage taken when wandering aimlessly. + * Can be a number between `0` - `100`. + * The higher the more damage taken (100% = instant KO). + */ +const DAMAGE_PERCENTAGE: number = 25; +/** The i18n namespace for the encounter */ +const namepsace = "mysteryEncounter:lostAtSea"; + +/** + * Lost at sea encounter. + * @see {@link https://github.com/AsdarDevelops/PokeRogue-Events/issues/9 | GitHub Issue #9} + * @see For biome requirements check [mysteryEncountersByBiome](../mystery-encounters.ts) + */ +export const LostAtSeaEncounter: MysteryEncounter = MysteryEncounterBuilder.withEncounterType(MysteryEncounterType.LOST_AT_SEA) + .withEncounterTier(MysteryEncounterTier.COMMON) + .withSceneWaveRangeRequirement(11, 179) + .withIntroSpriteConfigs([ + { + fileRoot: "mystery-encounters", + spriteKey: "buoy", + hasShadow: false, + x: 20, + y: 3, + }, + ]) + .withIntroDialogue([{ text: `${namepsace}:intro` }]) + .withOnInit((scene: BattleScene) => { + const { mysteryEncounter } = scene.currentBattle; + + mysteryEncounter.setDialogueToken("damagePercentage", String(DAMAGE_PERCENTAGE)); + mysteryEncounter.setDialogueToken("option1RequiredMove", Moves[OPTION_1_REQUIRED_MOVE]); + mysteryEncounter.setDialogueToken("option2RequiredMove", Moves[OPTION_2_REQUIRED_MOVE]); + + return true; + }) + .withTitle(`${namepsace}:title`) + .withDescription(`${namepsace}:description`) + .withQuery(`${namepsace}:query`) + .withOption( + // Option 1: Use a (non fainted) pokemon that can learn Surf to guide you back/ + new MysteryEncounterOptionBuilder() + .withPokemonCanLearnMoveRequirement(OPTION_1_REQUIRED_MOVE) + .withOptionMode(EncounterOptionMode.DISABLED_OR_DEFAULT) + .withDialogue({ + buttonLabel: `${namepsace}:option:1:label`, + disabledButtonLabel: `${namepsace}:option:1:label_disabled`, + buttonTooltip: `${namepsace}:option:1:tooltip`, + disabledButtonTooltip: `${namepsace}:option:1:tooltip_disabled`, + selected: [ + { + text: `${namepsace}:option:1:selected`, + }, + ], + }) + .withOptionPhase(async (scene: BattleScene) => handlePokemonGuidingYouPhase(scene)) + .build() + ) + .withOption( + //Option 2: Use a (non fainted) pokemon that can learn fly to guide you back. + new MysteryEncounterOptionBuilder() + .withPokemonCanLearnMoveRequirement(OPTION_2_REQUIRED_MOVE) + .withOptionMode(EncounterOptionMode.DISABLED_OR_DEFAULT) + .withDialogue({ + buttonLabel: `${namepsace}:option:2:label`, + disabledButtonLabel: `${namepsace}:option:2:label_disabled`, + buttonTooltip: `${namepsace}:option:2:tooltip`, + disabledButtonTooltip: `${namepsace}:option:2:tooltip_disabled`, + selected: [ + { + text: `${namepsace}:option:2:selected`, + }, + ], + }) + .withOptionPhase(async (scene: BattleScene) => handlePokemonGuidingYouPhase(scene)) + .build() + ) + .withSimpleOption( + // Option 3: Wander aimlessly + { + buttonLabel: `${namepsace}:option:3:label`, + buttonTooltip: `${namepsace}:option:3:tooltip`, + selected: [ + { + text: `${namepsace}:option:3:selected`, + }, + ], + }, + async (scene: BattleScene) => { + const allowedPokemon = scene.getParty().filter((p) => p.isAllowedInBattle()); + + for (const pkm of allowedPokemon) { + const percentage = DAMAGE_PERCENTAGE / 100; + const damage = Math.floor(pkm.getMaxHp() * percentage); + applyDamageToPokemon(scene, pkm, damage); + } + + leaveEncounterWithoutBattle(scene); + + return true; + } + ) + .withOutroDialogue([ + { + text: `${namepsace}:outro`, + }, + ]) + .build(); + +/** + * Generic handler for using a guiding pokemon to guide you back. + * + * @param scene Battle scene + * @param guidePokemon pokemon choosen as a guide + */ +function handlePokemonGuidingYouPhase(scene: BattleScene) { + const laprasSpecies = getPokemonSpecies(Species.LAPRAS); + const { mysteryEncounter } = scene.currentBattle; + + if (mysteryEncounter.selectedOption) { + setEncounterExp(scene, mysteryEncounter.selectedOption.primaryPokemon.id, laprasSpecies.baseExp, true); + } else { + console.warn("Lost at sea: No guide pokemon found but pokemon guides player. huh!?"); + } + + leaveEncounterWithoutBattle(scene); + return true; +} diff --git a/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts b/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts index 7773e03501b..9a0a42af2c8 100644 --- a/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts +++ b/src/data/mystery-encounters/encounters/mysterious-chest-encounter.ts @@ -1,10 +1,11 @@ +import { queueEncounterMessage, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; import { leaveEncounterWithoutBattle, setEncounterRewards } from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { getHighestLevelPlayerPokemon, koPlayerPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; import { ModifierTier } from "#app/modifier/modifier-tier"; -import { GameOverPhase } from "#app/phases"; -import { randSeedInt } from "#app/utils"; +import { randSeedInt } from "#app/utils.js"; import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import BattleScene from "../../../battle-scene"; import IMysteryEncounter, { @@ -12,8 +13,6 @@ import IMysteryEncounter, { MysteryEncounterTier, } from "../mystery-encounter"; import { EncounterOptionMode, MysteryEncounterOptionBuilder } from "../mystery-encounter-option"; -import { queueEncounterMessage, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; -import { getHighestLevelPlayerPokemon, koPlayerPokemon } from "#app/data/mystery-encounters/utils/encounter-pokemon-utils"; export const MysteriousChestEncounter: IMysteryEncounter = MysteryEncounterBuilder.withEncounterType( @@ -115,16 +114,9 @@ export const MysteriousChestEncounter: IMysteryEncounter = scene.currentBattle.mysteryEncounter.setDialogueToken("pokeName", highestLevelPokemon.name); // Show which Pokemon was KOed, then leave encounter with no rewards // Does this synchronously so that game over doesn't happen over result message - await showEncounterText(scene, "mysteryEncounter:mysterious_chest_option_1_bad_result") - .then(() => { - if (scene.getParty().filter((p) => p.isAllowedInBattle()).length === 0) { - // All pokemon fainted, game over - scene.clearPhaseQueue(); - scene.unshiftPhase(new GameOverPhase(scene)); - } else { - leaveEncounterWithoutBattle(scene); - } - }); + await showEncounterText(scene, "mysteryEncounter:mysterious_chest_option_1_bad_result").then(() => { + leaveEncounterWithoutBattle(scene); + }); } }) .build() diff --git a/src/data/mystery-encounters/mystery-encounter-option.ts b/src/data/mystery-encounters/mystery-encounter-option.ts index 7a65d77488e..c7feb2688c3 100644 --- a/src/data/mystery-encounters/mystery-encounter-option.ts +++ b/src/data/mystery-encounters/mystery-encounter-option.ts @@ -1,8 +1,11 @@ import { OptionTextDisplay } from "#app/data/mystery-encounters/mystery-encounter-dialogue"; +import { Moves } from "#app/enums/moves"; import { PlayerPokemon } from "#app/field/pokemon"; import BattleScene from "../../battle-scene"; import * as Utils from "../../utils"; -import { EncounterPokemonRequirement, EncounterSceneRequirement, MoneyRequirement } from "./mystery-encounter-requirements"; +import { Type } from "../type"; +import { EncounterPokemonRequirement, EncounterSceneRequirement, MoneyRequirement, TypeRequirement } from "./mystery-encounter-requirements"; +import { CanLearnMoveRequirement, CanLearnMoveRequirementOptions } from "./requirements/can-learn-move-requirement"; export enum EncounterOptionMode { /** Default style */ @@ -65,7 +68,6 @@ export default class MysteryEncounterOption implements MysteryEncounterOption { } let qualified: PlayerPokemon[] = scene.getParty(); for (const req of this.primaryPokemonRequirements) { - console.log(req); if (req.meetsRequirement(scene)) { if (req instanceof EncounterPokemonRequirement) { qualified = qualified.filter(pkmn => req.queryParty(scene.getParty()).includes(pkmn)); @@ -183,12 +185,42 @@ export class MysteryEncounterOptionBuilder implements Partial> { this.secondaryPokemonRequirements.push(requirement); this.excludePrimaryFromSecondaryRequirements = excludePrimaryFromSecondaryRequirements; return Object.assign(this, { secondaryPokemonRequirements: this.secondaryPokemonRequirements }); } + /** + * Se the full dialogue object to the option. Will override anything already set + * + * @param dialogue see {@linkcode OptionTextDisplay} + * @returns + */ withDialogue(dialogue: OptionTextDisplay) { this.dialogue = dialogue; return this; diff --git a/src/data/mystery-encounters/mystery-encounter-requirements.ts b/src/data/mystery-encounters/mystery-encounter-requirements.ts index 30e8c989ae8..6ec5d652fb4 100644 --- a/src/data/mystery-encounters/mystery-encounter-requirements.ts +++ b/src/data/mystery-encounters/mystery-encounter-requirements.ts @@ -31,12 +31,10 @@ export abstract class EncounterSceneRequirement implements EncounterRequirement } export abstract class EncounterPokemonRequirement implements EncounterRequirement { - minNumberOfPokemon: number; - invertQuery: boolean; + public minNumberOfPokemon: number; + public invertQuery: boolean; - meetsRequirement(scene: BattleScene): boolean { - throw new Error("Method not implemented."); - } + abstract meetsRequirement(scene: BattleScene): boolean; /** * Returns all party members that are compatible with this requirement. For non pokemon related requirements, the entire party is returned. @@ -331,11 +329,13 @@ export class NatureRequirement extends EncounterPokemonRequirement { export class TypeRequirement extends EncounterPokemonRequirement { requiredType: Type[]; + excludeFainted: boolean; minNumberOfPokemon: number; invertQuery: boolean; - constructor(type: Type | Type[], minNumberOfPokemon: number = 1, invertQuery: boolean = false) { + constructor(type: Type | Type[], excludeFainted: boolean = true, minNumberOfPokemon: number = 1, invertQuery: boolean = false) { super(); + this.excludeFainted = excludeFainted; this.minNumberOfPokemon = minNumberOfPokemon; this.invertQuery = invertQuery; if (type instanceof Array) { @@ -347,10 +347,16 @@ export class TypeRequirement extends EncounterPokemonRequirement { } meetsRequirement(scene: BattleScene): boolean { - const partyPokemon = scene.getParty(); + let partyPokemon = scene.getParty(); + if (isNullOrUndefined(partyPokemon) || this?.requiredType?.length < 0) { return false; } + + if (!this.excludeFainted) { + partyPokemon = partyPokemon.filter((pokemon) => !pokemon.isFainted()); + } + return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; } @@ -946,3 +952,5 @@ export class WeightRequirement extends EncounterPokemonRequirement { return ["weight", pokemon.getWeight().toString()]; } } + + diff --git a/src/data/mystery-encounters/mystery-encounter.ts b/src/data/mystery-encounters/mystery-encounter.ts index efad93e2f28..489bbd84d0a 100644 --- a/src/data/mystery-encounters/mystery-encounter.ts +++ b/src/data/mystery-encounters/mystery-encounter.ts @@ -214,7 +214,6 @@ export default class IMysteryEncounter implements IMysteryEncounter { } let qualified: PlayerPokemon[] = scene.getParty(); for (const req of this.primaryPokemonRequirements) { - console.log(req); if (req.meetsRequirement(scene)) { if (req instanceof EncounterPokemonRequirement) { qualified = qualified.filter(pkmn => req.queryParty(scene.getParty()).includes(pkmn)); @@ -400,8 +399,11 @@ export class MysteryEncounterBuilder implements Partial { } /** - * Defines an option for the encounter - * There should be at least 2 options defined and no more than 4 + * Defines an option for the encounter. + * Use for complex options. + * There should be at least 2 options defined and no more than 4. + * If easy/streamlined use {@linkcode MysteryEncounterBuilder.withOptionPhase} + * * @param option - MysteryEncounterOption to add, can use MysteryEncounterOptionBuilder to create instance * @returns */ @@ -417,8 +419,10 @@ export class MysteryEncounterBuilder implements Partial { } /** - * Adds a streamlined option phase. - * Only use if no pre-/post-options or condtions necessary. + * Defines an option + phasefor the encounter. + * Use for easy/streamlined options. + * There should be at least 2 options defined and no more than 4. + * If complex use {@linkcode MysteryEncounterBuilder.withOption} * * @param dialogue - {@linkcode OptionTextDisplay} * @param callback - {@linkcode OptionPhaseCallback} diff --git a/src/data/mystery-encounters/mystery-encounters.ts b/src/data/mystery-encounters/mystery-encounters.ts index c3016d8aaa2..77e22fce9e9 100644 --- a/src/data/mystery-encounters/mystery-encounters.ts +++ b/src/data/mystery-encounters/mystery-encounters.ts @@ -1,15 +1,16 @@ -import IMysteryEncounter from "./mystery-encounter"; +import { Biome } from "#enums/biome"; +import { MysteryEncounterType } from "#enums/mystery-encounter-type"; import { DarkDealEncounter } from "./encounters/dark-deal-encounter"; +import { DepartmentStoreSaleEncounter } from "./encounters/department-store-sale-encounter"; +import { FieldTripEncounter } from "./encounters/field-trip-encounter"; +import { FightOrFlightEncounter } from "./encounters/fight-or-flight-encounter"; +import { LostAtSeaEncounter } from "./encounters/lost-at-sea-encounter"; import { MysteriousChallengersEncounter } from "./encounters/mysterious-challengers-encounter"; import { MysteriousChestEncounter } from "./encounters/mysterious-chest-encounter"; -import { FightOrFlightEncounter } from "./encounters/fight-or-flight-encounter"; -import { TrainingSessionEncounter } from "./encounters/training-session-encounter"; -import { Biome } from "#enums/biome"; -import { SleepingSnorlaxEncounter } from "./encounters/sleeping-snorlax-encounter"; -import { MysteryEncounterType } from "#enums/mystery-encounter-type"; -import { DepartmentStoreSaleEncounter } from "./encounters/department-store-sale-encounter"; import { ShadyVitaminDealerEncounter } from "./encounters/shady-vitamin-dealer-encounter"; -import { FieldTripEncounter } from "./encounters/field-trip-encounter"; +import { SleepingSnorlaxEncounter } from "./encounters/sleeping-snorlax-encounter"; +import { TrainingSessionEncounter } from "./encounters/training-session-encounter"; +import IMysteryEncounter from "./mystery-encounter"; import { SafariZoneEncounter } from "#app/data/mystery-encounters/encounters/safari-zone-encounter"; import { FieryFalloutEncounter } from "#app/data/mystery-encounters/encounters/fiery-fallout-encounter"; @@ -167,7 +168,10 @@ export const mysteryEncountersByBiome = new Map([ MysteryEncounterType.SLEEPING_SNORLAX, MysteryEncounterType.SAFARI_ZONE ]], - [Biome.SEA, []], + + [Biome.SEA, [ + MysteryEncounterType.LOST_AT_SEA + ]], [Biome.SWAMP, [ MysteryEncounterType.SAFARI_ZONE ]], @@ -218,6 +222,7 @@ export function initMysteryEncounters() { allMysteryEncounters[MysteryEncounterType.SHADY_VITAMIN_DEALER] = ShadyVitaminDealerEncounter; allMysteryEncounters[MysteryEncounterType.FIELD_TRIP] = FieldTripEncounter; allMysteryEncounters[MysteryEncounterType.SAFARI_ZONE] = SafariZoneEncounter; + allMysteryEncounters[MysteryEncounterType.LOST_AT_SEA] = LostAtSeaEncounter; allMysteryEncounters[MysteryEncounterType.FIERY_FALLOUT] = FieryFalloutEncounter; // Add extreme encounters to biome map diff --git a/src/data/mystery-encounters/requirements/can-learn-move-requirement.ts b/src/data/mystery-encounters/requirements/can-learn-move-requirement.ts new file mode 100644 index 00000000000..fb1d65342c1 --- /dev/null +++ b/src/data/mystery-encounters/requirements/can-learn-move-requirement.ts @@ -0,0 +1,93 @@ +import BattleScene from "#app/battle-scene"; +import { Moves } from "#app/enums/moves"; +import { PlayerPokemon } from "#app/field/pokemon"; +import { isNullOrUndefined } from "#app/utils"; +import { EncounterPokemonRequirement } from "../mystery-encounter-requirements"; + +/** + * {@linkcode CanLearnMoveRequirement} options + */ +export interface CanLearnMoveRequirementOptions { + excludeLevelMoves?: boolean; + excludeTmMoves?: boolean; + excludeEggMoves?: boolean; + includeFainted?: boolean; + minNumberOfPokemon?: number; + invertQuery?: boolean; +} + +/** + * Requires that a pokemon can learn a specific move/moveset. + */ +export class CanLearnMoveRequirement extends EncounterPokemonRequirement { + private readonly requiredMoves: Moves[]; + private readonly excludeLevelMoves?: boolean; + private readonly excludeTmMoves?: boolean; + private readonly excludeEggMoves?: boolean; + private readonly includeFainted?: boolean; + + constructor(requiredMoves: Moves | Moves[], options: CanLearnMoveRequirementOptions = {}) { + super(); + this.requiredMoves = Array.isArray(requiredMoves) ? requiredMoves : [requiredMoves]; + + const { excludeLevelMoves, excludeTmMoves, excludeEggMoves, includeFainted, minNumberOfPokemon, invertQuery } = options; + + this.excludeLevelMoves = excludeLevelMoves ?? false; + this.excludeTmMoves = excludeTmMoves ?? false; + this.excludeEggMoves = excludeEggMoves ?? false; + this.includeFainted = includeFainted ?? false; + this.minNumberOfPokemon = minNumberOfPokemon ?? 1; + this.invertQuery = invertQuery; + } + + override meetsRequirement(scene: BattleScene): boolean { + const partyPokemon = scene.getParty().filter((pkm) => (this.includeFainted ? pkm.isAllowed() : pkm.isAllowedInBattle())); + + if (isNullOrUndefined(partyPokemon) || this?.requiredMoves?.length < 0) { + return false; + } + + return this.queryParty(partyPokemon).length >= this.minNumberOfPokemon; + } + + override queryParty(partyPokemon: PlayerPokemon[]): PlayerPokemon[] { + if (!this.invertQuery) { + return partyPokemon.filter((pokemon) => + // every required move should be included + this.requiredMoves.every((requiredMove) => this.getAllPokemonMoves(pokemon).includes(requiredMove)) + ); + } else { + return partyPokemon.filter( + (pokemon) => + // none of the "required" moves should be included + !this.requiredMoves.some((requiredMove) => this.getAllPokemonMoves(pokemon).includes(requiredMove)) + ); + } + } + + override getDialogueToken(_scene: BattleScene, _pokemon?: PlayerPokemon): [string, string] { + return ["requiredMoves", this.requiredMoves.join(", ")]; + } + + private getPokemonLevelMoves(pkm: PlayerPokemon): Moves[] { + return pkm.getLevelMoves().map(([_level, move]) => move); + } + + private getAllPokemonMoves(pkm: PlayerPokemon): Moves[] { + const allPokemonMoves: Moves[] = []; + + if (!this.excludeLevelMoves) { + allPokemonMoves.push(...(this.getPokemonLevelMoves(pkm) ?? [])); + } + + if (!this.excludeTmMoves) { + allPokemonMoves.push(...(pkm.compatibleTms ?? [])); + } + + if (!this.excludeEggMoves) { + allPokemonMoves.push(...(pkm.getEggMoves() ?? [])); + } + + return allPokemonMoves; + } +} diff --git a/src/data/mystery-encounters/utils/encounter-phase-utils.ts b/src/data/mystery-encounters/utils/encounter-phase-utils.ts index ba6a154abd9..3c35263fae2 100644 --- a/src/data/mystery-encounters/utils/encounter-phase-utils.ts +++ b/src/data/mystery-encounters/utils/encounter-phase-utils.ts @@ -1,33 +1,34 @@ -import i18next from "i18next"; import { BattlerIndex, BattleType } from "#app/battle"; -import BattleScene from "../../../battle-scene"; -import PokemonSpecies from "../../pokemon-species"; -import { MysteryEncounterVariant } from "../mystery-encounter"; -import { Status, StatusEffect } from "../../status-effect"; -import { TrainerConfig, trainerConfigs, TrainerSlot } from "../../trainer-config"; +import { biomeLinks } from "#app/data/biomes"; +import MysteryEncounterOption from "#app/data/mystery-encounters/mystery-encounter-option"; +import { WIGHT_INCREMENT_ON_SPAWN_MISS } from "#app/data/mystery-encounters/mystery-encounters"; +import { queueEncounterMessage, showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; import Pokemon, { FieldPosition, PlayerPokemon } from "#app/field/pokemon"; -import Trainer, { TrainerVariant } from "../../../field/trainer"; +import { getPokemonNameWithAffix } from "#app/messages"; import { ExpBalanceModifier, ExpShareModifier, MultipleParticipantExpBonusModifier, PokemonExpBoosterModifier } from "#app/modifier/modifier"; import { CustomModifierSettings, getModifierPoolForType, ModifierPoolType, ModifierType, ModifierTypeFunc, ModifierTypeGenerator, ModifierTypeOption, modifierTypes, PokemonHeldItemModifierType, regenerateModifierPoolThresholds } from "#app/modifier/modifier-type"; -import { BattleEndPhase, EggLapsePhase, ExpPhase, ModifierRewardPhase, MovePhase, SelectModifierPhase, ShowPartyExpBarPhase, TrainerVictoryPhase } from "#app/phases"; -import { MysteryEncounterBattlePhase, MysteryEncounterPhase, MysteryEncounterRewardsPhase } from "#app/phases/mystery-encounter-phase"; -import * as Utils from "../../../utils"; -import { isNullOrUndefined } from "#app/utils"; -import { TrainerType } from "#enums/trainer-type"; -import { BattlerTagType } from "#enums/battler-tag-type"; -import PokemonData from "#app/system/pokemon-data"; -import { Biome } from "#enums/biome"; -import { biomeLinks } from "#app/data/biomes"; -import { Mode } from "#app/ui/ui"; -import { PartyOption, PartyUiMode } from "#app/ui/party-ui-handler"; -import { OptionSelectConfig, OptionSelectItem } from "#app/ui/abstact-option-select-ui-handler"; -import { WIGHT_INCREMENT_ON_SPAWN_MISS } from "#app/data/mystery-encounters/mystery-encounters"; import * as Overrides from "#app/overrides"; -import MysteryEncounterOption from "#app/data/mystery-encounters/mystery-encounter-option"; -import { showEncounterText } from "#app/data/mystery-encounters/utils/encounter-dialogue-utils"; +import { BattleEndPhase, EggLapsePhase, ExpPhase, GameOverPhase, ModifierRewardPhase, MovePhase, SelectModifierPhase, ShowPartyExpBarPhase, TrainerVictoryPhase } from "#app/phases"; +import { MysteryEncounterBattlePhase, MysteryEncounterPhase, MysteryEncounterRewardsPhase } from "#app/phases/mystery-encounter-phase"; +import PokemonData from "#app/system/pokemon-data"; +import { OptionSelectConfig, OptionSelectItem } from "#app/ui/abstact-option-select-ui-handler"; +import { PartyOption, PartyUiMode } from "#app/ui/party-ui-handler"; +import { Mode } from "#app/ui/ui"; +import { isNullOrUndefined } from "#app/utils"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { Biome } from "#enums/biome"; +import { TrainerType } from "#enums/trainer-type"; +import i18next from "i18next"; +import BattleScene from "../../../battle-scene"; +import Trainer, { TrainerVariant } from "../../../field/trainer"; +import * as Utils from "../../../utils"; +import PokemonSpecies from "../../pokemon-species"; +import { Status, StatusEffect } from "../../status-effect"; +import { TrainerConfig, trainerConfigs, TrainerSlot } from "../../trainer-config"; +import { MysteryEncounterVariant } from "../mystery-encounter"; import { Gender } from "#app/data/gender"; -import { initMoveAnim, loadMoveAnimAssets } from "#app/data/battle-anims"; import { Moves } from "#enums/moves"; +import { initMoveAnim, loadMoveAnimAssets } from "#app/data/battle-anims"; export class EnemyPokemonConfig { species: PokemonSpecies; @@ -410,7 +411,7 @@ export function setEncounterRewards(scene: BattleScene, customShopRewards?: Cust * Will initialize exp phases into the phase queue (these are in addition to any combat or other exp earned) * Exp Share and Exp Balance will still function as normal * @param scene - Battle Scene - * @param participantIds - ids of party pokemon that get full exp value. Other party members will receive Exp Share amounts + * @param participantId - id/s of party pokemon that get full exp value. Other party members will receive Exp Share amounts * @param baseExpValue - gives exp equivalent to a pokemon of the wave index's level. * Guidelines: * 36 - Sunkern (lowest in game) @@ -424,7 +425,9 @@ export function setEncounterRewards(scene: BattleScene, customShopRewards?: Cust * https://bulbapedia.bulbagarden.net/wiki/List_of_Pok%C3%A9mon_by_effort_value_yield_(Generation_IX) * @param useWaveIndex - set to false when directly passing the the full exp value instead of baseExpValue */ -export function setEncounterExp(scene: BattleScene, participantIds: integer[], baseExpValue: number, useWaveIndex: boolean = true) { +export function setEncounterExp(scene: BattleScene, participantId: integer | integer[], baseExpValue: number, useWaveIndex: boolean = true) { + const participantIds = Array.isArray(participantId) ? participantId : [participantId]; + scene.currentBattle.mysteryEncounter.doEncounterExp = (scene: BattleScene) => { const party = scene.getParty(); const expShareModifier = scene.findModifier(m => m instanceof ExpShareModifier) as ExpShareModifier; @@ -544,6 +547,14 @@ export function leaveEncounterWithoutBattle(scene: BattleScene, addHealPhase: bo } export function handleMysteryEncounterVictory(scene: BattleScene, addHealPhase: boolean = false) { + const allowedPkm = scene.getParty().filter((pkm) => pkm.isAllowedInBattle()); + + if (allowedPkm.length === 0) { + scene.clearPhaseQueue(); + scene.unshiftPhase(new GameOverPhase(scene)); + return; + } + if (scene.currentBattle.mysteryEncounter.encounterVariant === MysteryEncounterVariant.SAFARI_BATTLE) { scene.pushPhase(new MysteryEncounterRewardsPhase(scene, addHealPhase)); } else if (scene.currentBattle.mysteryEncounter.encounterVariant === MysteryEncounterVariant.NO_BATTLE) { @@ -728,3 +739,65 @@ export function calculateMEAggregateStats(scene: BattleScene, baseSpawnWeight: n console.log(`Starting weight: ${baseSpawnWeight}\nAverage MEs per run: ${totalMean}\nStandard Deviation: ${totalStd}\nAvg Commons: ${commonMean}\nAvg Uncommons: ${uncommonMean}\nAvg Rares: ${rareMean}\nAvg Super Rares: ${superRareMean}`); } + +/** + * Takes care of handling player pokemon KO (with all its side effects) + * + * @param scene the battle scene + * @param pokemon the player pokemon to KO + */ +export function koPlayerPokemon(scene: BattleScene, pokemon: PlayerPokemon) { + pokemon.hp = 0; + pokemon.trySetStatus(StatusEffect.FAINT); + pokemon.updateInfo(); + queueEncounterMessage(scene, i18next.t("battle:fainted", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon) })); +} + +/** + * Handles applying hp changes to a player pokemon. + * Takes care of not going below `0`, above max-hp, adding `FNT` status correctly and updating the pokemon info. + * TODO: handle special cases like wonder-guard/ninjask + * @param scene the battle scene + * @param pokemon the player pokemon to apply the hp change to + * @param value the hp change amount. Positive for heal. Negative for damage + * + */ +function applyHpChangeToPokemon(scene: BattleScene, pokemon: PlayerPokemon, value: number) { + const hpChange = Math.round(pokemon.hp + value); + const nextHp = Math.max(Math.min(hpChange, pokemon.getMaxHp()), 0); + if (nextHp === 0) { + koPlayerPokemon(scene, pokemon); + } else { + pokemon.hp = nextHp; + } +} + +/** + * Handles applying damage to a player pokemon + * @param scene the battle scene + * @param pokemon the player pokemon to apply damage to + * @param damage the amount of damage to apply + * @see {@linkcode applyHpChangeToPokemon} + */ +export function applyDamageToPokemon(scene: BattleScene, pokemon: PlayerPokemon, damage: number) { + if (damage <= 0) { + console.warn("Healing pokemon with `applyDamageToPokemon` is not recommended! Please use `applyHealToPokemon` instead."); + } + + applyHpChangeToPokemon(scene, pokemon, -damage); +} + +/** + * Handles applying heal to a player pokemon + * @param scene the battle scene + * @param pokemon the player pokemon to apply heal to + * @param heal the amount of heal to apply + * @see {@linkcode applyHpChangeToPokemon} + */ +export function applyHealToPokemon(scene: BattleScene, pokemon: PlayerPokemon, heal: number) { + if (heal <= 0) { + console.warn("Damaging pokemong with `applyHealToPokemon` is not recommended! Please use `applyDamageToPokemon` instead."); + } + + applyHpChangeToPokemon(scene, pokemon, heal); +} diff --git a/src/enums/mystery-encounter-type.ts b/src/enums/mystery-encounter-type.ts index de15b50f2b8..a863ce46020 100644 --- a/src/enums/mystery-encounter-type.ts +++ b/src/enums/mystery-encounter-type.ts @@ -9,5 +9,6 @@ export enum MysteryEncounterType { SHADY_VITAMIN_DEALER, FIELD_TRIP, SAFARI_ZONE, + LOST_AT_SEA, //might be generalized later on FIERY_FALLOUT } diff --git a/src/field/mystery-encounter-intro.ts b/src/field/mystery-encounter-intro.ts index 2c606ad61cc..bacf107f482 100644 --- a/src/field/mystery-encounter-intro.ts +++ b/src/field/mystery-encounter-intro.ts @@ -2,18 +2,50 @@ import { GameObjects } from "phaser"; import BattleScene from "../battle-scene"; import IMysteryEncounter from "../data/mystery-encounters/mystery-encounter"; +type KnownFileRoot = + | "trainer" + | "pokemon" + | "arenas" + | "battle_anims" + | "cg" + | "character" + | "effect" + | "egg" + | "events" + | "inputs" + | "items" + | "mystery-encounters" + | "pokeball" + | "pokemon" + | "statuses" + | "trainer" + | "ui"; + export class MysteryEncounterSpriteConfig { - spriteKey: string; // e.g. "ace_trainer_f" - fileRoot: string; // "trainer" for trainer sprites, "pokemon" for pokemon, etc. Refer to /public/images directory for the folder name - hasShadow?: boolean = false; // Spawns shadow underneath sprite - disableAnimation?: boolean = false; // Animates frames or not - repeat?: boolean = false; // Cycles animation + /** The sprite key (which is the image file name). e.g. "ace_trainer_f" */ + spriteKey: string; + /** Refer to [/public/images](../../public/images) directorty for all folder names */ + fileRoot: KnownFileRoot & string; + /** Enable shadow. Defaults to `false` */ + hasShadow?: boolean = false; + /** Disable animation. Defaults to `false` */ + disableAnimation?: boolean = false; + /** Repeat the animation. Defaults to `false` */ + repeat?: boolean = false; + /** Tint color. `0` - `1`. Higher means darker tint. */ tint?: number; - x?: number; // X offset - y?: number; // Y offset + /** X offset */ + x?: number; + /** Y offset */ + y?: number; + /** Y shadow offset */ yShadowOffset?: number; + /** Sprite scale. `0` - `n` */ scale?: number; - isItem?: boolean; // For item sprites, set to true + /** If you are using an item sprite, set to `true` */ + isItem?: boolean; + /** The sprites alpha. `0` - `1` The lower the number, the more transparent */ + alpha?: number; } /** @@ -60,32 +92,35 @@ export default class MysteryEncounterIntroVisuals extends Phaser.GameObjects.Con const spacingValue = Math.round((maxX - minX) / Math.max(this.spriteConfigs.filter(s => !s.x && !s.y).length, 1)); this.spriteConfigs?.forEach((config) => { + const { spriteKey, isItem, hasShadow, scale, x, y, yShadowOffset, alpha } = config; + let sprite: GameObjects.Sprite; let tintSprite: GameObjects.Sprite; - if (!config.isItem) { - sprite = getSprite(config.spriteKey, config.hasShadow, config.yShadowOffset); - tintSprite = getSprite(config.spriteKey); + + if (!isItem) { + sprite = getSprite(spriteKey, hasShadow, yShadowOffset); + tintSprite = getSprite(spriteKey); } else { - sprite = getItemSprite(config.spriteKey); - tintSprite = getItemSprite(config.spriteKey); + sprite = getItemSprite(spriteKey); + tintSprite = getItemSprite(spriteKey); } tintSprite.setVisible(false); - if (config.scale) { - sprite.setScale(config.scale); - tintSprite.setScale(config.scale); + if (scale) { + sprite.setScale(scale); + tintSprite.setScale(scale); } // Sprite offset from origin - if (config.x || config.y) { - if (config.x) { - sprite.setPosition(origin + config.x, sprite.y); - tintSprite.setPosition(origin + config.x, tintSprite.y); + if (x || y) { + if (x) { + sprite.setPosition(origin + x, sprite.y); + tintSprite.setPosition(origin + x, tintSprite.y); } - if (config.y) { - sprite.setPosition(sprite.x, sprite.y + config.y); - tintSprite.setPosition(tintSprite.x, tintSprite.y + config.y); + if (y) { + sprite.setPosition(sprite.x, sprite.y + y); + tintSprite.setPosition(tintSprite.x, tintSprite.y + y); } } else { // Single sprite @@ -100,6 +135,11 @@ export default class MysteryEncounterIntroVisuals extends Phaser.GameObjects.Con } } + if (alpha) { + sprite.setAlpha(alpha); + tintSprite.setAlpha(alpha); + } + this.add(sprite); this.add(tintSprite); }); diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 08119931084..ebae5fe8122 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -274,9 +274,18 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * @returns {boolean} True if pokemon is allowed in battle */ isAllowedInBattle(): boolean { + return !this.isFainted() && this.isAllowed(); + } + + /** + * Check if this pokemon is allowed (no challenge exclusion) + * This is frequently a better alternative to {@link isFainted} + * @returns {boolean} True if pokemon is allowed in battle + */ + isAllowed(): boolean { const challengeAllowed = new Utils.BooleanHolder(true); applyChallenges(this.scene.gameMode, ChallengeType.POKEMON_IN_BATTLE, this, challengeAllowed); - return !this.isFainted() && challengeAllowed.value; + return challengeAllowed.value; } isActive(onField?: boolean): boolean { @@ -1319,6 +1328,15 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { return ret; } + /** + * Get a list of all egg moves + * + * @returns list of egg moves + */ + getEggMoves() : Moves[] { + return speciesEggMoves[this.species.speciesId]; + } + setMove(moveIndex: integer, moveId: Moves): void { const move = moveId ? new PokemonMove(moveId) : null; this.moveset[moveIndex] = move; diff --git a/src/locales/en/mystery-encounter.ts b/src/locales/en/mystery-encounter.ts index 68d6877f53d..a23518aac60 100644 --- a/src/locales/en/mystery-encounter.ts +++ b/src/locales/en/mystery-encounter.ts @@ -1,4 +1,4 @@ -import { SimpleTranslationEntries } from "#app/interfaces/locales"; +import { lostAtSea } from "./mystery-encounters/lost-at-sea"; /** * Patterns that can be used: @@ -13,7 +13,7 @@ import { SimpleTranslationEntries } from "#app/interfaces/locales"; * Any '(+)' or '(-)' type of tooltip will auto-color to green/blue respectively. THIS ONLY OCCURS FOR OPTION TOOLTIPS, NOWHERE ELSE * Other types of '(...)' tooltips will have to specify the text color manually by using '@[SUMMARY_GREEN]{}' pattern */ -export const mysteryEncounter: SimpleTranslationEntries = { +export const mysteryEncounter = { // DO NOT REMOVE "unit_test_dialogue": "{{test}}{{test}} {{test{{test}}}} {{test1}} {{test\}} {{test\\}} {{test\\\}} {test}}", @@ -260,4 +260,5 @@ export const mysteryEncounter: SimpleTranslationEntries = { $@s{item_fanfare}You gained a Berry!`, "sleeping_snorlax_option_3_good_result": "Your {{option3PrimaryName}} uses {{option3PrimaryMove}}! @s{item_fanfare}It steals Leftovers off the sleeping Snorlax and you make out like bandits!", + lostAtSea, } as const; diff --git a/src/locales/en/mystery-encounters/lost-at-sea.ts b/src/locales/en/mystery-encounters/lost-at-sea.ts new file mode 100644 index 00000000000..6e37168a255 --- /dev/null +++ b/src/locales/en/mystery-encounters/lost-at-sea.ts @@ -0,0 +1,31 @@ +export const lostAtSea = { + intro: "Wandering aimlessly, you effectively get nowhere.", + title: "Lost at sea", + description: "The sea is turbulent in this area, and you seem to be running out of fuel.\nThis is bad. Is there a way out of the situation?", + query: "What will you do?", + option: { + 1: { + label: "{{option1PrimaryName}} can help", + label_disabled: "Can't {{option1RequiredMove}}", + tooltip: "(+) {{option1PrimaryName}} saves you.\n(+) {{option1PrimaryName}} gains some EXP.", + tooltip_disabled: "You have no Pokémon to {{option1RequiredMove}} on", + selected: + "{{option1PrimaryName}} swims ahead, guiding you back on track.\n{{option1PrimaryName}} seems to also have gotten stronger in this time of need.", + }, + 2: { + label: "{{option2PrimaryName}} can help", + label_disabled: "Can't {{option2RequiredMove}}", + tooltip: "(+) {{option2PrimaryName}} saves you.\n(+) {{option2PrimaryName}} gains some EXP.", + tooltip_disabled: "You have no Pokémon to {{option2RequiredMove}} with", + selected: + "{{option2PrimaryName}} flies ahead of your boat, guiding you back on track.\n{{option2PrimaryName}} seems to also have gotten stronger in this time of need.", + }, + 3: { + label: "Wander aimlessly", + tooltip: "(-) Each of your Pokémon lose {{damagePercentage}}% of their total HP.", + selected: `You float about in the boat, steering it aimlessly until you finally get back on track. + $You and your Pokémon get very fatigued during the whole ordeal.`, + }, + }, + outro: "You are back on track." +}; diff --git a/src/phases/mystery-encounter-phase.ts b/src/phases/mystery-encounter-phase.ts index 5745e2c68d1..b0acdd3c1e1 100644 --- a/src/phases/mystery-encounter-phase.ts +++ b/src/phases/mystery-encounter-phase.ts @@ -4,7 +4,7 @@ import { Phase } from "../phase"; import { Mode } from "../ui/ui"; import { hideMysteryEncounterIntroVisuals, OptionSelectSettings } from "../data/mystery-encounters/utils/encounter-phase-utils"; import { CheckSwitchPhase, NewBattlePhase, ReturnPhase, ScanIvsPhase, SelectModifierPhase, SummonPhase, ToggleDoublePositionPhase } from "../phases"; -import MysteryEncounterOption from "../data/mystery-encounters/mystery-encounter-option"; +import MysteryEncounterOption, { OptionPhaseCallback } from "../data/mystery-encounters/mystery-encounter-option"; import { MysteryEncounterVariant } from "../data/mystery-encounters/mystery-encounter"; import { getCharVariantFromDialogue } from "../data/dialogue"; import { TrainerSlot } from "../data/trainer-config"; @@ -138,7 +138,7 @@ export class MysteryEncounterPhase extends Phase { * Any phase that is meant to follow this one MUST be queued via the onOptionSelect() logic of the selected option */ export class MysteryEncounterOptionSelectedPhase extends Phase { - onOptionSelect: (scene: BattleScene) => Promise; + onOptionSelect: OptionPhaseCallback; constructor(scene: BattleScene) { super(scene); @@ -397,7 +397,7 @@ export class MysteryEncounterRewardsPhase extends Phase { * - Queuing of the next wave */ export class PostMysteryEncounterPhase extends Phase { - onPostOptionSelect: (scene: BattleScene) => Promise; + onPostOptionSelect: OptionPhaseCallback; constructor(scene: BattleScene) { super(scene); diff --git a/src/test/abilities/quick_draw.test.ts b/src/test/abilities/quick_draw.test.ts index 884b108381d..aa66d7e052f 100644 --- a/src/test/abilities/quick_draw.test.ts +++ b/src/test/abilities/quick_draw.test.ts @@ -52,7 +52,7 @@ describe("Abilities - Quick Draw", () => { expect(pokemon.battleData.abilitiesApplied).contain(Abilities.QUICK_DRAW); }, 20000); - test("does not triggered by non damage moves", async () => { + test("does not triggered by non damage moves", { timeout: 20000, retry: 5 }, async () => { await game.startBattle([Species.SLOWBRO]); const pokemon = game.scene.getPlayerPokemon(); @@ -67,7 +67,7 @@ describe("Abilities - Quick Draw", () => { expect(pokemon.isFainted()).toBe(true); expect(enemy.isFainted()).toBe(false); expect(pokemon.battleData.abilitiesApplied).not.contain(Abilities.QUICK_DRAW); - }, 20000); + }); test("does not increase priority", async () => { vi.spyOn(Overrides, "OPP_MOVESET_OVERRIDE", "get").mockReturnValue(Array(4).fill(Moves.EXTREME_SPEED)); diff --git a/src/test/mystery-encounter/encounterTestUtils.ts b/src/test/mystery-encounter/encounterTestUtils.ts new file mode 100644 index 00000000000..65f6ae0edc9 --- /dev/null +++ b/src/test/mystery-encounter/encounterTestUtils.ts @@ -0,0 +1,54 @@ +import { Button } from "#app/enums/buttons"; +import { MessagePhase } from "#app/phases"; +import { MysteryEncounterPhase, MysteryEncounterRewardsPhase } from "#app/phases/mystery-encounter-phase"; +import MysteryEncounterUiHandler from "#app/ui/mystery-encounter-ui-handler"; +import { Mode } from "#app/ui/ui"; +import GameManager from "../utils/gameManager"; + +export async function runSelectMysteryEncounterOption(game: GameManager, optionNo: number) { + if (game.isCurrentPhase(MessagePhase)) { + // Handle eventual weather messages (e.g. a downpour started!) + game.onNextPrompt("MessagePhase", Mode.MESSAGE, () => { + const uiHandler = game.scene.ui.getHandler(); + uiHandler.processInput(Button.ACTION); + }); + await game.phaseInterceptor.run(MessagePhase); + } + + // dispose of intro messages + game.onNextPrompt("MysteryEncounterPhase", Mode.MESSAGE, () => { + const uiHandler = game.scene.ui.getHandler(); + uiHandler.processInput(Button.ACTION); + }); + // select the desired option + game.onNextPrompt("MysteryEncounterPhase", Mode.MYSTERY_ENCOUNTER, () => { + const uiHandler = game.scene.ui.getHandler(); + uiHandler.unblockInput(); // input are blocked by 1s to prevent accidental input. Tests need to handle that + + switch (optionNo) { + case 1: + // no movement needed. Default cursor position + break; + case 2: + uiHandler.processInput(Button.RIGHT); + break; + case 3: + uiHandler.processInput(Button.DOWN); + break; + case 4: + uiHandler.processInput(Button.RIGHT); + uiHandler.processInput(Button.DOWN); + break; + } + + uiHandler.processInput(Button.ACTION); + }); + await game.phaseInterceptor.run(MysteryEncounterPhase); + + // run the selected options phase + game.onNextPrompt("MysteryEncounterOptionSelectedPhase", Mode.MESSAGE, () => { + const uiHandler = game.scene.ui.getHandler(); + uiHandler.processInput(Button.ACTION); + }); + await game.phaseInterceptor.to(MysteryEncounterRewardsPhase); +} diff --git a/src/test/mystery-encounter/encounters/lost-at-sea-encounter.test.ts b/src/test/mystery-encounter/encounters/lost-at-sea-encounter.test.ts new file mode 100644 index 00000000000..3b8017fa4c6 --- /dev/null +++ b/src/test/mystery-encounter/encounters/lost-at-sea-encounter.test.ts @@ -0,0 +1,247 @@ +import Battle from "#app/battle"; +import { LostAtSeaEncounter } from "#app/data/mystery-encounters/encounters/lost-at-sea-encounter"; +import { EncounterOptionMode } from "#app/data/mystery-encounters/mystery-encounter-option"; +import * as MysteryEncounters from "#app/data/mystery-encounters/mystery-encounters"; +import * as EncounterPhaseUtils from "#app/data/mystery-encounters/utils/encounter-phase-utils"; +import { getPokemonSpecies } from "#app/data/pokemon-species.js"; +import { Biome } from "#app/enums/biome"; +import { Moves } from "#app/enums/moves"; +import { MysteryEncounterType } from "#app/enums/mystery-encounter-type"; +import { Species } from "#app/enums/species"; +import GameManager from "#app/test/utils/gameManager"; +import { workaround_reInitSceneWithOverrides } from "#app/test/utils/testUtils"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { runSelectMysteryEncounterOption } from "../encounterTestUtils"; + +const namepsace = "mysteryEncounter:lostAtSea"; +/** Blastoise for surf. Pidgeot for fly. Abra for none. */ +const defaultParty = [Species.BLASTOISE, Species.PIDGEOT, Species.ABRA]; +const defaultBiome = Biome.SEA; +const defaultWave = 33; + +describe("Lost at Sea - Mystery Encounter", () => { + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ type: Phaser.HEADLESS }); + }); + + beforeEach(async () => { + game = new GameManager(phaserGame); + game.override.mysteryEncounterChance(100); + game.override.startingBiome(defaultBiome); + game.override.startingWave(defaultWave); + vi.spyOn(MysteryEncounters, "mysteryEncountersByBiome", "get").mockReturnValue( + new Map([ + [Biome.SEA, [MysteryEncounterType.LOST_AT_SEA]], + [Biome.MOUNTAIN, [MysteryEncounterType.MYSTERIOUS_CHALLENGERS]], + ]) + ); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + it("should have the correct properties", async () => { + await workaround_reInitSceneWithOverrides(game); + await game.runToMysteryEncounter(defaultParty); + + expect(LostAtSeaEncounter.encounterType).toBe(MysteryEncounterType.LOST_AT_SEA); + expect(LostAtSeaEncounter.dialogue).toBeDefined(); + expect(LostAtSeaEncounter.dialogue.intro).toStrictEqual([{ text: `${namepsace}:intro` }]); + expect(LostAtSeaEncounter.dialogue.encounterOptionsDialogue.title).toBe(`${namepsace}:title`); + expect(LostAtSeaEncounter.dialogue.encounterOptionsDialogue.description).toBe(`${namepsace}:description`); + expect(LostAtSeaEncounter.dialogue.encounterOptionsDialogue.query).toBe(`${namepsace}:query`); + expect(LostAtSeaEncounter.options.length).toBe(3); + }); + + it("should not spawn outside of sea biome", async () => { + game.override.startingBiome(Biome.MOUNTAIN); + await workaround_reInitSceneWithOverrides(game); + await game.runToMysteryEncounter(); + + expect(game.scene.currentBattle.mysteryEncounter.encounterType).not.toBe(MysteryEncounterType.LOST_AT_SEA); + }); + + it("should not run below wave 11", async () => { + game.override.startingWave(10); + + await game.runToMysteryEncounter(); + + expect(game.scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should not run above wave 179", async () => { + game.override.startingWave(180); + + await game.runToMysteryEncounter(); + + expect(game.scene.currentBattle.mysteryEncounter).toBeUndefined(); + }); + + it("should set the correct dialog tokens during initialization", () => { + vi.spyOn(game.scene, "currentBattle", "get").mockReturnValue({ mysteryEncounter: LostAtSeaEncounter } as Battle); + + const { onInit } = LostAtSeaEncounter; + + expect(LostAtSeaEncounter.onInit).toBeDefined(); + + const onInitResult = onInit(game.scene); + + expect(LostAtSeaEncounter.dialogueTokens?.damagePercentage).toBe("25"); + expect(LostAtSeaEncounter.dialogueTokens?.option1RequiredMove).toBe(Moves[Moves.SURF]); + expect(LostAtSeaEncounter.dialogueTokens?.option2RequiredMove).toBe(Moves[Moves.FLY]); + expect(onInitResult).toBe(true); + }); + + describe("Option 1 - Surf", () => { + it("should have the correct properties", () => { + const option1 = LostAtSeaEncounter.options[0]; + expect(option1.optionMode).toBe(EncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option1.dialogue).toBeDefined(); + expect(option1.dialogue).toStrictEqual({ + buttonLabel: `${namepsace}:option:1:label`, + disabledButtonLabel: `${namepsace}:option:1:label_disabled`, + buttonTooltip: `${namepsace}:option:1:tooltip`, + disabledButtonTooltip: `${namepsace}:option:1:tooltip_disabled`, + selected: [ + { + text: `${namepsace}:option:1:selected`, + }, + ], + }); + }); + + it("should award exp to surfable PKM (Blastoise)", async () => { + const laprasSpecies = getPokemonSpecies(Species.LAPRAS); + + await workaround_reInitSceneWithOverrides(game); + await game.runToMysteryEncounter(defaultParty); + const party = game.scene.getParty(); + const blastoise = party.find((pkm) => pkm.species.speciesId === Species.PIDGEOT); + const expBefore = blastoise.exp; + + await runSelectMysteryEncounterOption(game, 2); + + expect(blastoise.exp).toBe(expBefore + laprasSpecies.baseExp * defaultWave); + }); + + it("should leave encounter without battle", async () => { + game.override.startingWave(33); + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await workaround_reInitSceneWithOverrides(game); + await game.runToMysteryEncounter(defaultParty); + await runSelectMysteryEncounterOption(game, 1); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + + it("should be disabled if no surfable PKM is in party", async () => { + // TODO + }); + }); + + describe("Option 2 - Fly", () => { + it("should have the correct properties", () => { + const option2 = LostAtSeaEncounter.options[1]; + + expect(option2.optionMode).toBe(EncounterOptionMode.DISABLED_OR_DEFAULT); + expect(option2.dialogue).toBeDefined(); + expect(option2.dialogue).toStrictEqual({ + buttonLabel: `${namepsace}:option:2:label`, + disabledButtonLabel: `${namepsace}:option:2:label_disabled`, + buttonTooltip: `${namepsace}:option:2:tooltip`, + disabledButtonTooltip: `${namepsace}:option:2:tooltip_disabled`, + selected: [ + { + text: `${namepsace}:option:2:selected`, + }, + ], + }); + }); + + it("should award exp to flyable PKM (Pidgeot)", async () => { + const laprasBaseExp = 187; + const wave = 33; + game.override.startingWave(wave); + + await workaround_reInitSceneWithOverrides(game); + await game.runToMysteryEncounter(defaultParty); + const party = game.scene.getParty(); + const pidgeot = party.find((pkm) => pkm.species.speciesId === Species.PIDGEOT); + const expBefore = pidgeot.exp; + + await runSelectMysteryEncounterOption(game, 2); + + expect(pidgeot.exp).toBe(expBefore + laprasBaseExp * wave); + }); + + it("should leave encounter without battle", async () => { + game.override.startingWave(33); + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + await workaround_reInitSceneWithOverrides(game); + await game.runToMysteryEncounter(defaultParty); + await runSelectMysteryEncounterOption(game, 2); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + + it("should be disabled if no flyable PKM is in party", async () => { + // TODO + }); + }); + + describe("Option 3 - Wander aimlessy", () => { + it("should have the correct properties", () => { + const option3 = LostAtSeaEncounter.options[2]; + + expect(option3.optionMode).toBe(EncounterOptionMode.DEFAULT); + expect(option3.dialogue).toBeDefined(); + expect(option3.dialogue).toStrictEqual({ + buttonLabel: `${namepsace}:option:3:label`, + buttonTooltip: `${namepsace}:option:3:tooltip`, + selected: [ + { + text: `${namepsace}:option:3:selected`, + }, + ], + }); + }); + + it("should damage all (allowed in battle) party PKM by 25%", async () => { + game.override.startingWave(33); + + await workaround_reInitSceneWithOverrides(game); + await game.runToMysteryEncounter(defaultParty); + + const party = game.scene.getParty(); + const abra = party.find((pkm) => pkm.species.speciesId === Species.ABRA); + vi.spyOn(abra, "isAllowedInBattle").mockReturnValue(false); + + await runSelectMysteryEncounterOption(game, 3); + + const allowedPkm = party.filter((pkm) => pkm.isAllowedInBattle()); + const notAllowedPkm = party.filter((pkm) => !pkm.isAllowedInBattle()); + allowedPkm.forEach((pkm) => + expect(pkm.hp, `${pkm.name} should have receivd 25% damage: ${pkm.hp} / ${pkm.getMaxHp()} HP`).toBe(pkm.getMaxHp() - Math.floor(pkm.getMaxHp() * 0.25)) + ); + + notAllowedPkm.forEach((pkm) => expect(pkm.hp, `${pkm.name} should be full hp: ${pkm.hp} / ${pkm.getMaxHp()} HP`).toBe(pkm.getMaxHp())); + }); + + it("should leave encounter without battle", async () => { + game.override.startingWave(33); + const leaveEncounterWithoutBattleSpy = vi.spyOn(EncounterPhaseUtils, "leaveEncounterWithoutBattle"); + + workaround_reInitSceneWithOverrides(game); + await game.runToMysteryEncounter(defaultParty); + await runSelectMysteryEncounterOption(game, 3); + + expect(leaveEncounterWithoutBattleSpy).toBeCalled(); + }); + }); +}); diff --git a/src/test/utils/gameManager.ts b/src/test/utils/gameManager.ts index 4550891dcf9..6c750c60a92 100644 --- a/src/test/utils/gameManager.ts +++ b/src/test/utils/gameManager.ts @@ -35,6 +35,7 @@ import { BattlerIndex } from "#app/battle.js"; import TargetSelectUiHandler from "#app/ui/target-select-ui-handler.js"; import BattleMessageUiHandler from "#app/ui/battle-message-ui-handler"; import {MysteryEncounterPhase} from "#app/phases/mystery-encounter-phase"; +import { OverridesHelper } from "./overridesHelper"; /** * Class to manage the game state and transitions between phases. @@ -45,6 +46,7 @@ export default class GameManager { public phaseInterceptor: PhaseInterceptor; public textInterceptor: TextInterceptor; public inputsHandler: InputsHandler; + public readonly override: OverridesHelper; /** * Creates an instance of GameManager. @@ -60,6 +62,7 @@ export default class GameManager { this.phaseInterceptor = new PhaseInterceptor(this.scene); this.textInterceptor = new TextInterceptor(this.scene); this.gameWrapper.setScene(this.scene); + this.override = new OverridesHelper(); } /** diff --git a/src/test/utils/overridesHelper.ts b/src/test/utils/overridesHelper.ts new file mode 100644 index 00000000000..dd45d972b50 --- /dev/null +++ b/src/test/utils/overridesHelper.ts @@ -0,0 +1,64 @@ +import { Weather, WeatherType } from "#app/data/weather"; +import { Biome } from "#app/enums/biome"; +import * as Overrides from "#app/overrides"; +import { vi } from "vitest"; + +/** + * Helper to handle overrides in tests + */ +export class OverridesHelper { + constructor() {} + + /** + * Override the encounter chance for a mystery encounter. + * @param percentage the encounter chance in % + */ + mysteryEncounterChance(percentage: number) { + const maxRate: number = 256; // 100% + const rate = maxRate * (percentage / 100); + vi.spyOn(Overrides, "MYSTERY_ENCOUNTER_RATE_OVERRIDE", "get").mockReturnValue(rate); + this.log(`Mystery encounter chance set to ${percentage}% (=${rate})!`); + } + + /** + * Override the starting biome + * @warning The biome will not be overridden unless you call `workaround_reInitSceneWithOverrides()` (testUtils) + * @param biome the biome to set + */ + startingBiome(biome: Biome) { + vi.spyOn(Overrides, "STARTING_BIOME_OVERRIDE", "get").mockReturnValue(biome); + this.log(`Starting biome set to ${Biome[biome]} (=${biome})!`); + } + + /** + * Override the starting wave (index) + * @param wave the wave (index) to set. Classic: `1`-`200` + */ + startingWave(wave: number) { + vi.spyOn(Overrides, "STARTING_WAVE_OVERRIDE", "get").mockReturnValue(wave); + this.log(`Starting wave set to ${wave}!`); + } + + /** + * Override the weather (type) + * @param type weather type to set + */ + weather(type: WeatherType) { + vi.spyOn(Overrides, "WEATHER_OVERRIDE", "get").mockReturnValue(type); + this.log(`Weather set to ${Weather[type]} (=${type})!`); + } + + /** + * Override the seed + * @warning The seed will not be overridden unless you call `workaround_reInitSceneWithOverrides()` (testUtils) + * @param seed the seed to set + */ + seed(seed: string) { + vi.spyOn(Overrides, "SEED_OVERRIDE", "get").mockReturnValue(seed); + this.log(`Seed set to "${seed}"!`); + } + + private log(...params: any[]) { + console.log("Overrides:", ...params); + } +} diff --git a/src/test/utils/phaseInterceptor.ts b/src/test/utils/phaseInterceptor.ts index cc593030b09..71cf6b495ed 100644 --- a/src/test/utils/phaseInterceptor.ts +++ b/src/test/utils/phaseInterceptor.ts @@ -8,6 +8,7 @@ import { EncounterPhase, EnemyCommandPhase, FaintPhase, + LearnMovePhase, LoginPhase, MessagePhase, MoveEffectPhase, @@ -41,6 +42,7 @@ import { MysteryEncounterBattlePhase, MysteryEncounterOptionSelectedPhase, MysteryEncounterPhase, + MysteryEncounterRewardsPhase, PostMysteryEncounterPhase } from "#app/phases/mystery-encounter-phase"; @@ -100,11 +102,13 @@ export default class PhaseInterceptor { [MysteryEncounterPhase, this.startPhase], [MysteryEncounterOptionSelectedPhase, this.startPhase], [MysteryEncounterBattlePhase, this.startPhase], - [PostMysteryEncounterPhase, this.startPhase] + [MysteryEncounterRewardsPhase, this.startPhase], + [PostMysteryEncounterPhase, this.startPhase], + [LearnMovePhase, this.startPhase] ]; private endBySetMode = [ - TitlePhase, SelectGenderPhase, CommandPhase, SelectModifierPhase + TitlePhase, SelectGenderPhase, CommandPhase, SelectModifierPhase, PostMysteryEncounterPhase ]; /** diff --git a/src/test/utils/testUtils.ts b/src/test/utils/testUtils.ts index b922fc9c61c..a8461b3a5db 100644 --- a/src/test/utils/testUtils.ts +++ b/src/test/utils/testUtils.ts @@ -1,5 +1,6 @@ import i18next, { type ParseKeys } from "i18next"; import { vi } from "vitest"; +import GameManager from "./gameManager"; /** * Sets up the i18next mock. @@ -21,3 +22,15 @@ export function mockI18next() { export function arrayOfRange(start: integer, end: integer) { return Array.from({ length: end - start }, (_v, k) => k + start); } + +/** + * Woraround to reinitialize the game scene with overrides being set properly. + * By default the scene is initialized without all overrides even having a chance to be applied. + * @warning USE AT YOUR OWN RISK! Might be deleted in the future + * @param game The game manager + * @deprecated + */ +export async function workaround_reInitSceneWithOverrides(game: GameManager) { + await game.runToTitle(); + game.gameWrapper.setScene(game.scene); +} diff --git a/src/ui/mystery-encounter-ui-handler.ts b/src/ui/mystery-encounter-ui-handler.ts index d1ce9fb5d04..e6699afbfe8 100644 --- a/src/ui/mystery-encounter-ui-handler.ts +++ b/src/ui/mystery-encounter-ui-handler.ts @@ -135,7 +135,7 @@ export default class MysteryEncounterUiHandler extends UiHandler { // TODO: If we need to handle cancel option? Maybe default logic to leave/run from encounter idk } } else { - switch (this.optionsContainer.length) { + switch (this.optionsContainer.list.length) { case 3: success = this.handleTwoOptionMoveInput(button); break; diff --git a/src/ui/ui.ts b/src/ui/ui.ts index fa7dc9fad9f..aac2779e111 100644 --- a/src/ui/ui.ts +++ b/src/ui/ui.ts @@ -234,8 +234,8 @@ export default class UI extends Phaser.GameObjects.Container { (this.scene as BattleScene).uiContainer.add(this.tooltipContainer); } - getHandler(): UiHandler { - return this.handlers[this.mode]; + getHandler(): H { + return this.handlers[this.mode] as H; } getMessageHandler(): BattleMessageUiHandler {