Merge pull request #37 from flx-sta/event/getting-lost-at-the-sea

Implement `Lost at sea` (Common)
This commit is contained in:
flx-sta 2024-07-17 17:56:17 -07:00 committed by GitHub
commit 7a24f68769
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 940 additions and 99 deletions

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -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

View File

@ -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;
}

View File

@ -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()

View File

@ -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<MysteryEncounterOp
return Object.assign(this, { primaryPokemonRequirements: this.primaryPokemonRequirements });
}
/**
* Player is required to have certain type/s of pokemon in his party (with optional min number of pokemons with that type)
*
* @param type the required type/s
* @param excludeFainted whether to exclude fainted pokemon
* @param minNumberOfPokemon number of pokemons to have that type
* @param invertQuery
* @returns
*/
withPokemonTypeRequirement(type: Type | Type[], excludeFainted?: boolean, minNumberOfPokemon?: number, invertQuery?: boolean) {
return this.withPrimaryPokemonRequirement(new TypeRequirement(type, excludeFainted, minNumberOfPokemon, invertQuery));
}
/**
* Player is required to have a pokemon that can learn a certain move/moveset
*
* @param move the required move/moves
* @param options see {@linkcode CanLearnMoveRequirementOptions}
* @returns
*/
withPokemonCanLearnMoveRequirement(move: Moves | Moves[], options?: CanLearnMoveRequirementOptions) {
return this.withPrimaryPokemonRequirement(new CanLearnMoveRequirement(move, options));
}
withSecondaryPokemonRequirement(requirement: EncounterPokemonRequirement, excludePrimaryFromSecondaryRequirements?: boolean): this & Required<Pick<MysteryEncounterOption, "secondaryPokemonRequirements">> {
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;

View File

@ -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()];
}
}

View File

@ -198,7 +198,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));
@ -382,8 +381,11 @@ export class MysteryEncounterBuilder implements Partial<IMysteryEncounter> {
}
/**
* 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
*/
@ -399,8 +401,10 @@ export class MysteryEncounterBuilder implements Partial<IMysteryEncounter> {
}
/**
* 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}

View File

@ -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";
// Spawn chance: (BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT + WIGHT_INCREMENT_ON_SPAWN_MISS * <number of missed spawns>) / 256
@ -166,7 +167,10 @@ export const mysteryEncountersByBiome = new Map<Biome, MysteryEncounterType[]>([
MysteryEncounterType.SLEEPING_SNORLAX,
MysteryEncounterType.SAFARI_ZONE
]],
[Biome.SEA, []],
[Biome.SEA, [
MysteryEncounterType.LOST_AT_SEA
]],
[Biome.SWAMP, [
MysteryEncounterType.SAFARI_ZONE
]],
@ -215,6 +219,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;
// Add extreme encounters to biome map
extremeBiomeEncounters.forEach(encounter => {

View File

@ -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;
}
}

View File

@ -1,30 +1,31 @@
import i18next from "i18next";
import { 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, 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, 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";
export class EnemyPokemonConfig {
species: PokemonSpecies;
@ -385,7 +386,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)
@ -399,7 +400,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;
@ -519,6 +522,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) {
@ -673,3 +684,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);
}

View File

@ -8,5 +8,6 @@ export enum MysteryEncounterType {
DEPARTMENT_STORE_SALE,
SHADY_VITAMIN_DEALER,
FIELD_TRIP,
SAFARI_ZONE
SAFARI_ZONE,
LOST_AT_SEA //might be generalized later on
}

View File

@ -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);
});

View File

@ -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;

View File

@ -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]{<text>}' pattern
*/
export const mysteryEncounter: SimpleTranslationEntries = {
export const mysteryEncounter = {
// DO NOT REMOVE
"unit_test_dialogue": "{{test}}{{test}} {{test{{test}}}} {{test1}} {{test\}} {{test\\}} {{test\\\}} {test}}",
@ -240,4 +240,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;

View File

@ -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."
};

View File

@ -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<boolean | void>;
onOptionSelect: OptionPhaseCallback;
constructor(scene: BattleScene) {
super(scene);
@ -394,7 +394,7 @@ export class MysteryEncounterRewardsPhase extends Phase {
* - Queuing of the next wave
*/
export class PostMysteryEncounterPhase extends Phase {
onPostOptionSelect: (scene: BattleScene) => Promise<void | boolean>;
onPostOptionSelect: OptionPhaseCallback;
constructor(scene: BattleScene) {
super(scene);

View File

@ -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));

View File

@ -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<MysteryEncounterUiHandler>();
uiHandler.processInput(Button.ACTION);
});
await game.phaseInterceptor.run(MessagePhase);
}
// dispose of intro messages
game.onNextPrompt("MysteryEncounterPhase", Mode.MESSAGE, () => {
const uiHandler = game.scene.ui.getHandler<MysteryEncounterUiHandler>();
uiHandler.processInput(Button.ACTION);
});
// select the desired option
game.onNextPrompt("MysteryEncounterPhase", Mode.MYSTERY_ENCOUNTER, () => {
const uiHandler = game.scene.ui.getHandler<MysteryEncounterUiHandler>();
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<MysteryEncounterUiHandler>();
uiHandler.processInput(Button.ACTION);
});
await game.phaseInterceptor.to(MysteryEncounterRewardsPhase);
}

View File

@ -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, MysteryEncounterType[]>([
[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();
});
});
});

View File

@ -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();
}
/**

View File

@ -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);
}
}

View File

@ -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
];
/**

View File

@ -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);
}

View File

@ -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;

View File

@ -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 extends UiHandler = UiHandler>(): H {
return this.handlers[this.mode] as H;
}
getMessageHandler(): BattleMessageUiHandler {