start adding unit tests

This commit is contained in:
ImperialSympathizer 2024-07-07 00:46:26 -04:00
parent 07ffe1ca64
commit f803de6b23
17 changed files with 678 additions and 107 deletions

View File

@ -1,14 +1,14 @@
# 📝 Most immediate things to-do list
- ### High priority
- 🐛 Intimidate and other ETB abilities proc twice at the start of wild MEs (fight or flight, dark deal)
- ⚙️ Add a tag system so MEs don't show where they shouldn't and bricking Challenge runs:
- ⚙️ Add a tag system so MEs to filter or change spawn rates in Challenge runs:
- noChallenge (cant be spawned in challenge runs)
- allChallenge (can spawn in all challenge modes)
- (typespecific)Challenge:
- Example: fireOnly (can only spawn in fire related challenges)
- ### Medium priority
- ⚙️ Update Chest visuals for Mysterious Chest (with animated chest)
- ### Low priority
- 🐛 Mysterious Challengers can spawn two trainers (or three) of the same type [Dev comment: not a bug]
@ -106,9 +106,9 @@ Events (referred to as 'Mysterious Encounters, MEs' in the code) aim to be an ad
### 🌟 **Rarity** tier of the ME, common by default.
- ⚪ Common pool
- 🔵 Rare pool
- 🟣 Epic pool
- 🟡 Legendary pool
- 🔵 Uncommon pool
- 🟣 Rare pool
- 🟡 Super Rare pool
### **Optional Requirements** for Mystery Encounters.
- 🛠️ They give granular control over whether encounters will spawn in certain situations
@ -135,13 +135,10 @@ Events (referred to as 'Mysterious Encounters, MEs' in the code) aim to be an ad
# 📝 Known bugs (squash 'em all!):
- ## 🔴 __**Really bad ones**__
- 🐛 Picking up certain items in Fight or Flight is still broken. Workaround is leave encounter.
- 🐛 Modifiers that are applied to pokemon get skipped in Fight or Flight.
- ## 🟡 __**Bad ones under certain circumstances**__
- 🐛 Needs further replication : At wave 51, wild PKMN encounter caused a freezed after pressing "ESC" key upon being asked to switch PKMNs
- 🐛 Wave seed generates different encounter data if you roll to a new wave, see the spawned stuff, and refresh the app
- 🐛 Type-buffing items (like Silk Scarf) get swapped around when offered as a reward in Fight or Flight
- ## 🟢 __**Non-game breaking**__
- Both of these bugs seem to have in common that they don't "forget" their last passed string:
@ -157,7 +154,6 @@ Events (referred to as 'Mysterious Encounters, MEs' in the code) aim to be an ad
#### More requirements (with helper functions)
- Having X item
- Having Y amount of X item
- Being in a specific Biome
- A Pokémon X in player's party can learn Y move
- A Pokémon X in player's party knows Y move
- A Pokémon X in player's party has Y ability

View File

@ -93,7 +93,7 @@ import { UiTheme } from "#enums/ui-theme";
import { TimedEventManager } from "#app/timed-event-manager.js";
import i18next from "i18next";
import MysteryEncounter, { MysteryEncounterTier, MysteryEncounterVariant } from "./data/mystery-encounter";
import { mysteryEncountersByBiome, allMysteryEncounters, BASE_MYSTERY_ENCOUNTER_WEIGHT, AVERAGE_ENCOUNTERS_PER_RUN_TARGET } from "./data/mystery-encounters/mystery-encounters";
import { mysteryEncountersByBiome, allMysteryEncounters, BASE_MYSTERY_ENCOUNTER_WEIGHT, AVERAGE_ENCOUNTERS_PER_RUN_TARGET, WIGHT_INCREMENT_ON_SPAWN_MISS } from "./data/mystery-encounters/mystery-encounters";
import { MysteryEncounterFlags } from "#app/data/mystery-encounter-flags";
import { MysteryEncounterType } from "#enums/mystery-encounter-type";
@ -1098,22 +1098,22 @@ export default class BattleScene extends SceneBase {
// Check for mystery encounter
// Can only occur in place of a standard wild battle, waves 10-180
// let testStartingWeight = 10;
// while (testStartingWeight < 30) {
// let testStartingWeight = 0;
// while (testStartingWeight < 20) {
// calculateMEAggregateStats(this, testStartingWeight);
// testStartingWeight += 2;
// testStartingWeight += 1;
// }
if (this.gameMode.hasMysteryEncounters && newBattleType === BattleType.WILD && !this.gameMode.isBoss(newWaveIndex) && newWaveIndex < 180 && newWaveIndex > 10) {
const roll = Utils.randSeedInt(256);
// Base spawn weight is 3/256, and increases by 1/256 for each missed attempt at spawning an encounter on a valid floor
const sessionEncounterRate = !isNullOrUndefined(this.mysteryEncounterFlags?.encounterSpawnChance) ? this.mysteryEncounterFlags.encounterSpawnChance : BASE_MYSTERY_ENCOUNTER_WEIGHT;
// Base spawn weight is 1/256, and increases by 5/256 for each missed attempt at spawning an encounter on a valid floor
const sessionEncounterRate = !isNullOrUndefined(this.mysteryEncounterData?.encounterSpawnChance) ? this.mysteryEncounterData.encounterSpawnChance : BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT;
// If total number of encounters is lower than expected for the run, slightly favor a new encounter spawn
// Do the reverse as well
// Reduces occurrence of runs with very few (<6) and a ton (>10) of encounters
const expectedEncountersByFloor = AVERAGE_ENCOUNTERS_PER_RUN_TARGET / (180 - 10) * newWaveIndex;
const currentRunDiffFromAvg = expectedEncountersByFloor - (this.mysteryEncounterFlags?.encounteredEvents?.length || 0);
const currentRunDiffFromAvg = expectedEncountersByFloor - (this.mysteryEncounterData?.encounteredEvents?.length || 0);
const favoredEncounterRate = sessionEncounterRate + currentRunDiffFromAvg * 5;
const successRate = isNullOrUndefined(Overrides.MYSTERY_ENCOUNTER_RATE_OVERRIDE) ? favoredEncounterRate : Overrides.MYSTERY_ENCOUNTER_RATE_OVERRIDE;
@ -1121,9 +1121,9 @@ export default class BattleScene extends SceneBase {
if (roll < successRate) {
newBattleType = BattleType.MYSTERY_ENCOUNTER;
// Reset base spawn weight
this.mysteryEncounterFlags.encounterSpawnChance = BASE_MYSTERY_ENCOUNTER_WEIGHT;
this.mysteryEncounterData.encounterSpawnChance = BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT;
} else {
this.mysteryEncounterFlags.encounterSpawnChance = sessionEncounterRate + 1;
this.mysteryEncounterData.encounterSpawnChance = sessionEncounterRate + WIGHT_INCREMENT_ON_SPAWN_MISS;
}
}
}
@ -1168,7 +1168,9 @@ export default class BattleScene extends SceneBase {
if (newBattleType === BattleType.MYSTERY_ENCOUNTER) {
// Disable double battle on mystery encounters (it may be re-enabled as part of encounter)
this.currentBattle.double = false;
this.currentBattle.mysteryEncounter = this.getMysteryEncounter(mysteryEncounter);
this.executeWithSeedOffset(() => {
this.currentBattle.mysteryEncounter = this.getMysteryEncounter(mysteryEncounter);
}, this.currentBattle.waveIndex << 4);
}
//this.pushPhase(new TrainerMessageTestPhase(this, TrainerType.RIVAL, TrainerType.RIVAL_2, TrainerType.RIVAL_3, TrainerType.RIVAL_4, TrainerType.RIVAL_5, TrainerType.RIVAL_6));
@ -2652,10 +2654,10 @@ export default class BattleScene extends SceneBase {
}
// Check for queued encounters first
if (!encounter && this.mysteryEncounterFlags?.nextEncounterQueue?.length > 0) {
if (!encounter && this.mysteryEncounterData?.nextEncounterQueue?.length > 0) {
let i = 0;
while (i < this.mysteryEncounterFlags.nextEncounterQueue.length && !!encounter) {
const candidate = this.mysteryEncounterFlags.nextEncounterQueue[i];
while (i < this.mysteryEncounterData.nextEncounterQueue.length && !!encounter) {
const candidate = this.mysteryEncounterData.nextEncounterQueue[i];
const forcedChance = candidate[1];
if (Utils.randSeedInt(100) < forcedChance) {
encounter = allMysteryEncounters[candidate[0]];
@ -2675,7 +2677,7 @@ export default class BattleScene extends SceneBase {
const tierWeights = [61, 40, 21, 6];
// Adjust tier weights by previously encountered events to lower odds of only common/uncommons in run
this.mysteryEncounterFlags.encounteredEvents.forEach(val => {
this.mysteryEncounterData.encounteredEvents.forEach(val => {
const tier = val[1];
if (tier === MysteryEncounterTier.COMMON) {
tierWeights[0] = tierWeights[0] - 6;
@ -2697,7 +2699,7 @@ export default class BattleScene extends SceneBase {
let availableEncounters = [];
// New encounter will never be the same as the most recent encounter
const previousEncounter = this.mysteryEncounterFlags.encounteredEvents?.length > 0 ? this.mysteryEncounterFlags.encounteredEvents[this.mysteryEncounterFlags.encounteredEvents.length - 1][0] : null;
const previousEncounter = this.mysteryEncounterData.encounteredEvents?.length > 0 ? this.mysteryEncounterData.encounteredEvents[this.mysteryEncounterData.encounteredEvents.length - 1][0] : null;
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) {

View File

@ -1,14 +1,14 @@
import {MysteryEncounterTier} from "#app/data/mystery-encounter";
import {MysteryEncounterType} from "#enums/mystery-encounter-type";
import {BASE_MYSTERY_ENCOUNTER_WEIGHT} from "#app/data/mystery-encounters/mystery-encounters";
import {BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT} from "#app/data/mystery-encounters/mystery-encounters";
import {isNullOrUndefined} from "../utils";
export class MysteryEncounterFlags {
export class MysteryEncounterData {
encounteredEvents: [MysteryEncounterType, MysteryEncounterTier][] = [];
encounterSpawnChance: number = BASE_MYSTERY_ENCOUNTER_WEIGHT;
encounterSpawnChance: number = BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT;
nextEncounterQueue: [MysteryEncounterType, integer][] = [];
constructor(flags: MysteryEncounterFlags) {
constructor(flags: MysteryEncounterData) {
if (!isNullOrUndefined(flags)) {
Object.assign(this, flags);
}

View File

@ -63,11 +63,11 @@ export class PreviousEncounterRequirement extends EncounterSceneRequirement {
}
meetsRequirement(scene: BattleScene): boolean {
return scene.mysteryEncounterFlags.encounteredEvents.some(e => e[0] === this.previousEncounterRequirement);
return scene.mysteryEncounterData.encounteredEvents.some(e => e[0] === this.previousEncounterRequirement);
}
getDialogueToken(scene: BattleScene, pokemon?: PlayerPokemon): [string, string] {
return ["previousEncounter", scene.mysteryEncounterFlags.encounteredEvents.find(e => e[0] === this.previousEncounterRequirement)[0].toString()];
return ["previousEncounter", scene.mysteryEncounterData.encounteredEvents.find(e => e[0] === this.previousEncounterRequirement)[0].toString()];
}
}

View File

@ -27,10 +27,10 @@ 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 {EncounterSceneRequirement} from "#app/data/mystery-encounter-requirements";
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";
/**
*
@ -62,15 +62,15 @@ export function getRandomPlayerPokemon(scene: BattleScene, isAllowedInBattle: bo
return chosenPokemon;
}
export function getTokensFromScene(scene: BattleScene, reqs: EncounterSceneRequirement[]): Array<[RegExp, String]> {
const arr = [];
if (scene) {
for (const req of reqs) {
req.getDialogueToken(scene);
}
}
return arr;
}
// export function getTokensFromScene(scene: BattleScene, reqs: EncounterSceneRequirement[]): Array<[RegExp, String]> {
// const arr = [];
// if (scene) {
// for (const req of reqs) {
// req.getDialogueToken(scene);
// }
// }
// return arr;
// }
/**
* Ties are broken by whatever mon is closer to the front of the party
@ -114,6 +114,48 @@ export function getLowestLevelPlayerPokemon(scene: BattleScene, unfainted: boole
return pokemon;
}
/**
*
* NOTE: This returns ANY random species, including those locked behind eggs, etc.
* @param starterTiers
* @param excludedSpecies
* @param types
* @returns
*/
export function getRandomSpeciesByStarterTier(starterTiers: number | [number, number], excludedSpecies?: Species[], types?: Type[]): Species {
let min = starterTiers instanceof Array ? starterTiers[0] : starterTiers;
let max = starterTiers instanceof Array ? starterTiers[1] : starterTiers;
let filteredSpecies: [PokemonSpecies, number][] = Object.keys(speciesStarters)
.map(s => [parseInt(s) as Species, speciesStarters[s] as number])
.filter(s => getPokemonSpecies(s[0]) && (!excludedSpecies || !excludedSpecies.includes(s[0])))
.map(s => [getPokemonSpecies(s[0]), s[1]]);
if (!isNullOrUndefined(types) && types.length > 0) {
filteredSpecies = filteredSpecies.filter(s => types.includes(s[0].type1) || types.includes(s[0].type2));
}
// If no filtered mons exist at specified starter tiers, will expand starter search range until there are
// Starts by decrementing starter tier min until it is 0, then increments tier max up to 10
let tryFilterStarterTiers: [PokemonSpecies, number][] = filteredSpecies.filter(s => (s[1] >= min && s[1] <= max));
while (tryFilterStarterTiers.length === 0 && (min !== 0 && max !== 10)) {
if (min > 0) {
min--;
} else {
max++;
}
tryFilterStarterTiers = filteredSpecies.filter(s => s[1] >= min && s[1] <= max);
}
if (tryFilterStarterTiers.length > 0) {
const index = Utils.randSeedInt(tryFilterStarterTiers.length);
return Phaser.Math.RND.shuffle(tryFilterStarterTiers)[index][0].speciesId;
}
return Species.BULBASAUR;
}
export function koPlayerPokemon(pokemon: PlayerPokemon) {
pokemon.hp = 0;
pokemon.trySetStatus(StatusEffect.FAINT);
@ -173,48 +215,6 @@ export function showEncounterDialogue(scene: BattleScene, textContentKey: Templa
scene.ui.showDialogue(text, speaker, null, callback, 0, 0);
}
/**
*
* NOTE: This returns ANY random species, including those locked behind eggs, etc.
* @param starterTiers
* @param excludedSpecies
* @param types
* @returns
*/
export function getRandomSpeciesByStarterTier(starterTiers: number | [number, number], excludedSpecies?: Species[], types?: Type[]): Species {
let min = starterTiers instanceof Array ? starterTiers[0] : starterTiers;
let max = starterTiers instanceof Array ? starterTiers[1] : starterTiers;
let filteredSpecies: [PokemonSpecies, number][] = Object.keys(speciesStarters)
.map(s => [parseInt(s) as Species, speciesStarters[s] as number])
.filter(s => getPokemonSpecies(s[0]) && !excludedSpecies.includes(s[0]))
.map(s => [getPokemonSpecies(s[0]), s[1]]);
if (!isNullOrUndefined(types) && types.length > 0) {
filteredSpecies = filteredSpecies.filter(s => types.includes(s[0].type1) || types.includes(s[0].type2));
}
// If no filtered mons exist at specified starter tiers, will expand starter search range until there are
// Starts by decrementing starter tier min until it is 0, then increments tier max up to 10
let tryFilterStarterTiers: [PokemonSpecies, number][] = filteredSpecies.filter(s => (s[1] >= min && s[1] <= max));
while (tryFilterStarterTiers.length === 0 && (min !== 0 && max !== 10)) {
if (min > 0) {
min--;
} else {
max++;
}
tryFilterStarterTiers = filteredSpecies.filter(s => s[1] >= min && s[1] <= max);
}
if (tryFilterStarterTiers.length > 0) {
const index = Utils.randSeedInt(tryFilterStarterTiers.length);
return Phaser.Math.RND.shuffle(tryFilterStarterTiers)[index][0].speciesId;
}
return Species.BULBASAUR;
}
export class EnemyPokemonConfig {
species: PokemonSpecies;
isBoss: boolean = false;
@ -594,10 +594,10 @@ export function handleMysteryEncounterVictory(scene: BattleScene, addHealPhase:
export function calculateMEAggregateStats(scene: BattleScene, baseSpawnWeight: number) {
const numRuns = 1000;
let run = 0;
const targetEncountersPerRun = 15;
const targetEncountersPerRun = 15; // AVERAGE_ENCOUNTERS_PER_RUN_TARGET
const calculateNumEncounters = (): number[] => {
let encounterRate = baseSpawnWeight;
let encounterRate = baseSpawnWeight; // BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT
const numEncounters = [0, 0, 0, 0];
let currentBiome = Biome.TOWN;
let currentArena = scene.newArena(currentBiome);
@ -669,7 +669,7 @@ export function calculateMEAggregateStats(scene: BattleScene, baseSpawnWeight: n
tierValue > commonThreshold ? ++numEncounters[0] : tierValue > uncommonThreshold ? ++numEncounters[1] : tierValue > rareThreshold ? ++numEncounters[2] : ++numEncounters[3];
} else {
encounterRate++;
encounterRate += WIGHT_INCREMENT_ON_SPAWN_MISS;
}
}

View File

@ -8,7 +8,9 @@ import { Biome } from "#app/enums/biome";
import { SleepingSnorlaxEncounter } from "./sleeping-snorlax";
import { MysteryEncounterType } from "#enums/mystery-encounter-type";
export const BASE_MYSTERY_ENCOUNTER_WEIGHT = 19;
// Spawn chance: (BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT + WIGHT_INCREMENT_ON_SPAWN_MISS * <number of missed spawns>) / 256
export const BASE_MYSTERY_ENCOUNTER_SPAWN_WEIGHT = 1;
export const WIGHT_INCREMENT_ON_SPAWN_MISS = 5;
export const AVERAGE_ENCOUNTERS_PER_RUN_TARGET = 15;
export const allMysteryEncounters : {[encounterType:string]: MysteryEncounter} = {};

View File

@ -1,6 +1,8 @@
import {SimpleTranslationEntries} from "#app/interfaces/locales";
export const mysteryEncounter: SimpleTranslationEntries = {
// DO NOT REMOVE
"unit_test_dialogue": "@ec{test}@ec{test} @ec{test@ec{test}} @ec{test1} @ec{test\} @ec{test\\} @ec{test\\\} {test}",
// Mysterious Encounters -- Common Tier

View File

@ -817,7 +817,7 @@ export class EncounterPhase extends BattlePhase {
if (mysteryEncounter.onInit) {
mysteryEncounter.onInit(this.scene);
}
mysteryEncounter.populateDialogueTokensFromRequirements();
mysteryEncounter.populateDialogueTokensFromRequirements(this.scene);
}, this.scene.currentBattle.waveIndex);
// Add intro visuals for mystery encounter

View File

@ -40,7 +40,7 @@ export class MysteryEncounterPhase extends Phase {
// Sets flag that ME was encountered
// Can be used in later MEs to check for requirements to spawn, etc.
this.scene.mysteryEncounterFlags.encounteredEvents.push([this.scene.currentBattle.mysteryEncounter.encounterType, this.scene.currentBattle.mysteryEncounter.encounterTier]);
this.scene.mysteryEncounterData.encounteredEvents.push([this.scene.currentBattle.mysteryEncounter.encounterType, this.scene.currentBattle.mysteryEncounter.encounterTier]);
// Initiates encounter dialogue window and option select
this.scene.ui.setMode(Mode.MYSTERY_ENCOUNTER);
@ -55,7 +55,7 @@ export class MysteryEncounterPhase extends Phase {
}
// Populate dialogue tokens for option requirements
this.scene.currentBattle.mysteryEncounter.populateDialogueTokensFromRequirements();
this.scene.currentBattle.mysteryEncounter.populateDialogueTokensFromRequirements(this.scene);
if (option.onPreOptionPhase) {
this.scene.executeWithSeedOffset(async () => {

View File

@ -40,7 +40,7 @@ import { GameDataType } from "#enums/game-data-type";
import { Moves } from "#enums/moves";
import { PlayerGender } from "#enums/player-gender";
import { Species } from "#enums/species";
import { MysteryEncounterFlags } from "../data/mystery-encounter-flags";
import { MysteryEncounterData } from "../data/mystery-encounter-data";
import MysteryEncounter from "../data/mystery-encounter";
export const defaultStarterSpecies: Species[] = [
@ -125,7 +125,7 @@ export interface SessionSaveData {
timestamp: integer;
challenges: ChallengeData[];
mysteryEncounter: MysteryEncounter;
mysteryEncounterFlags: MysteryEncounterFlags;
mysteryEncounterFlags: MysteryEncounterData;
}
interface Unlocks {
@ -842,7 +842,7 @@ export class GameData {
timestamp: new Date().getTime(),
challenges: scene.gameMode.challenges.map(c => new ChallengeData(c)),
mysteryEncounter: scene.currentBattle.mysteryEncounter,
mysteryEncounterFlags: scene.mysteryEncounterFlags
mysteryEncounterFlags: scene.mysteryEncounterData
} as SessionSaveData;
}
@ -933,7 +933,7 @@ export class GameData {
scene.score = sessionData.score;
scene.updateScoreText();
scene.mysteryEncounterFlags = sessionData?.mysteryEncounterFlags ? sessionData?.mysteryEncounterFlags : new MysteryEncounterFlags(null);
scene.mysteryEncounterData = sessionData?.mysteryEncounterFlags ? sessionData?.mysteryEncounterFlags : new MysteryEncounterData(null);
scene.newArena(sessionData.arena.biome);
@ -1159,7 +1159,7 @@ export class GameData {
}
if (k === "mysteryEncounterFlags") {
return new MysteryEncounterFlags(v);
return new MysteryEncounterData(v);
}
return v;

View File

@ -0,0 +1,366 @@
import {afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import GameManager from "#app/test/utils/gameManager";
import Phaser from "phaser";
import {
getHighestLevelPlayerPokemon, getLowestLevelPlayerPokemon,
getRandomPlayerPokemon, getRandomSpeciesByStarterTier, getTextWithEncounterDialogueTokens,
koPlayerPokemon, queueEncounterMessage, showEncounterDialogue, showEncounterText,
} from "#app/data/mystery-encounters/mystery-encounter-utils";
import {initSceneWithoutEncounterPhase} from "#test/utils/gameManagerUtils";
import {Species} from "#enums/species";
import BattleScene from "#app/battle-scene";
import {StatusEffect} from "#app/data/status-effect";
import MysteryEncounter from "#app/data/mystery-encounter";
import {MessagePhase} from "#app/phases";
import {getPokemonSpecies, speciesStarters} from "#app/data/pokemon-species";
import {Type} from "#app/data/type";
describe("Mystery Encounter Utils", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
let scene: BattleScene;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
scene = game.scene;
initSceneWithoutEncounterPhase(game.scene, [Species.ARCEUS, Species.MANAPHY]);
// vi.spyOn(overrides, "MYSTERY_ENCOUNTER_RATE_OVERRIDE", "get").mockReturnValue(256);
// vi.spyOn(overrides, "STARTING_WAVE_OVERRIDE", "get").mockReturnValue(11);
});
describe("getRandomPlayerPokemon", () => {
it("gets a random pokemon from player party", () => {
// Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal)
scene.waveSeed = "random";
Phaser.Math.RND.sow([ scene.waveSeed ]);
scene.rngCounter = 0;
let result = getRandomPlayerPokemon(scene);
expect(result.species.speciesId).toBe(Species.MANAPHY);
scene.waveSeed = "random2";
Phaser.Math.RND.sow([ scene.waveSeed ]);
scene.rngCounter = 0;
result = getRandomPlayerPokemon(scene);
expect(result.species.speciesId).toBe(Species.ARCEUS);
});
it("gets a fainted pokemon from player party if isAllowedInBattle is false", () => {
// Both pokemon fainted
scene.getParty().forEach(p => {
p.hp = 0;
p.trySetStatus(StatusEffect.FAINT);
p.updateInfo();
});
// Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal)
scene.waveSeed = "random";
Phaser.Math.RND.sow([ scene.waveSeed ]);
scene.rngCounter = 0;
let result = getRandomPlayerPokemon(scene);
expect(result.species.speciesId).toBe(Species.MANAPHY);
scene.waveSeed = "random2";
Phaser.Math.RND.sow([ scene.waveSeed ]);
scene.rngCounter = 0;
result = getRandomPlayerPokemon(scene);
expect(result.species.speciesId).toBe(Species.ARCEUS);
});
it("gets an unfainted pokemon from player party if isAllowedInBattle is true", () => {
// Only faint 1st pokemon
const party = scene.getParty();
party[0].hp = 0;
party[0].trySetStatus(StatusEffect.FAINT);
party[0].updateInfo();
// Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal)
scene.waveSeed = "random";
Phaser.Math.RND.sow([ scene.waveSeed ]);
scene.rngCounter = 0;
let result = getRandomPlayerPokemon(scene, true);
expect(result.species.speciesId).toBe(Species.MANAPHY);
scene.waveSeed = "random2";
Phaser.Math.RND.sow([ scene.waveSeed ]);
scene.rngCounter = 0;
result = getRandomPlayerPokemon(scene, true);
expect(result.species.speciesId).toBe(Species.MANAPHY);
});
it("returns last unfainted pokemon if doNotReturnLastAbleMon is false", () => {
// Only faint 1st pokemon
const party = scene.getParty();
party[0].hp = 0;
party[0].trySetStatus(StatusEffect.FAINT);
party[0].updateInfo();
// Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal)
scene.waveSeed = "random";
Phaser.Math.RND.sow([ scene.waveSeed ]);
scene.rngCounter = 0;
let result = getRandomPlayerPokemon(scene, true, false);
expect(result.species.speciesId).toBe(Species.MANAPHY);
scene.waveSeed = "random2";
Phaser.Math.RND.sow([ scene.waveSeed ]);
scene.rngCounter = 0;
result = getRandomPlayerPokemon(scene, true, false);
expect(result.species.speciesId).toBe(Species.MANAPHY);
});
it("never returns last unfainted pokemon if doNotReturnLastAbleMon is true", () => {
// Only faint 1st pokemon
const party = scene.getParty();
party[0].hp = 0;
party[0].trySetStatus(StatusEffect.FAINT);
party[0].updateInfo();
// Seeds are calculated to return index 0 first, 1 second (if both pokemon are legal)
scene.waveSeed = "random";
Phaser.Math.RND.sow([ scene.waveSeed ]);
scene.rngCounter = 0;
let result = getRandomPlayerPokemon(scene, true, true);
expect(result.species.speciesId).toBe(Species.ARCEUS);
scene.waveSeed = "random2";
Phaser.Math.RND.sow([ scene.waveSeed ]);
scene.rngCounter = 0;
result = getRandomPlayerPokemon(scene, true, true);
expect(result.species.speciesId).toBe(Species.ARCEUS);
});
});
describe("getHighestLevelPlayerPokemon", () => {
it("gets highest level pokemon", () => {
const party = scene.getParty();
party[0].level = 100;
const result = getHighestLevelPlayerPokemon(scene);
expect(result.species.speciesId).toBe(Species.ARCEUS);
});
it("gets highest level pokemon at different index", () => {
const party = scene.getParty();
party[1].level = 100;
const result = getHighestLevelPlayerPokemon(scene);
expect(result.species.speciesId).toBe(Species.MANAPHY);
});
it("breaks ties by getting returning lower index", () => {
const party = scene.getParty();
party[0].level = 100;
party[1].level = 100;
const result = getHighestLevelPlayerPokemon(scene);
expect(result.species.speciesId).toBe(Species.ARCEUS);
});
it("returns highest level unfainted if unfainted is true", () => {
const party = scene.getParty();
party[0].level = 100;
party[0].hp = 0;
party[0].trySetStatus(StatusEffect.FAINT);
party[0].updateInfo();
party[1].level = 10;
const result = getHighestLevelPlayerPokemon(scene, true);
expect(result.species.speciesId).toBe(Species.MANAPHY);
});
});
describe("getLowestLevelPokemon", () => {
it("gets lowest level pokemon", () => {
const party = scene.getParty();
party[0].level = 100;
const result = getLowestLevelPlayerPokemon(scene);
expect(result.species.speciesId).toBe(Species.MANAPHY);
});
it("gets lowest level pokemon at different index", () => {
const party = scene.getParty();
party[1].level = 100;
const result = getLowestLevelPlayerPokemon(scene);
expect(result.species.speciesId).toBe(Species.ARCEUS);
});
it("breaks ties by getting returning lower index", () => {
const party = scene.getParty();
party[0].level = 100;
party[1].level = 100;
const result = getLowestLevelPlayerPokemon(scene);
expect(result.species.speciesId).toBe(Species.ARCEUS);
});
it("returns lowest level unfainted if unfainted is true", () => {
const party = scene.getParty();
party[0].level = 10;
party[0].hp = 0;
party[0].trySetStatus(StatusEffect.FAINT);
party[0].updateInfo();
party[1].level = 100;
const result = getLowestLevelPlayerPokemon(scene, true);
expect(result.species.speciesId).toBe(Species.MANAPHY);
});
});
describe("getRandomSpeciesByStarterTier", () => {
it("gets species for a starter tier", () => {
const result = getRandomSpeciesByStarterTier(5);
const pokeSpecies = getPokemonSpecies(result);
expect(pokeSpecies.speciesId).toBe(result);
expect(speciesStarters[result]).toBe(5);
});
it("gets species for a starter tier range", () => {
const result = getRandomSpeciesByStarterTier([5, 8]);
const pokeSpecies = getPokemonSpecies(result);
expect(pokeSpecies.speciesId).toBe(result);
expect(speciesStarters[result]).toBeGreaterThanOrEqual(5);
expect(speciesStarters[result]).toBeLessThanOrEqual(8);
});
it("excludes species from search", () => {
// Only 9 tiers are: Koraidon, Miraidon, Arceus, Rayquaza, Kyogre, Groudon, Zacian
const result = getRandomSpeciesByStarterTier(9, [Species.KORAIDON, Species.MIRAIDON, Species.ARCEUS, Species.RAYQUAZA, Species.KYOGRE, Species.GROUDON]);
const pokeSpecies = getPokemonSpecies(result);
expect(pokeSpecies.speciesId).toBe(Species.ZACIAN);
});
it("gets species of specified types", () => {
// Only 9 tiers are: Koraidon, Miraidon, Arceus, Rayquaza, Kyogre, Groudon, Zacian
const result = getRandomSpeciesByStarterTier(9, null, [Type.GROUND]);
const pokeSpecies = getPokemonSpecies(result);
expect(pokeSpecies.speciesId).toBe(Species.GROUDON);
});
});
describe("koPlayerPokemon", () => {
it("KOs a pokemon", () => {
const party = scene.getParty();
const arceus = party[0];
arceus.hp = 100;
expect(arceus.isAllowedInBattle()).toBe(true);
koPlayerPokemon(arceus);
expect(arceus.isAllowedInBattle()).toBe(false);
});
});
describe("getTextWithEncounterDialogueTokens", () => {
it("injects dialogue tokens", () => {
scene.currentBattle.mysteryEncounter = new MysteryEncounter(null);
scene.currentBattle.mysteryEncounter.setDialogueToken("test", "value");
const result = getTextWithEncounterDialogueTokens(scene, "mysteryEncounter:unit_test_dialogue");
expect(result).toEqual("valuevalue @ec{testvalue} @ec{test1} value @ec{test\\} @ec{test\\} {test}");
});
it("can perform nested dialogue token injection", () => {
scene.currentBattle.mysteryEncounter = new MysteryEncounter(null);
scene.currentBattle.mysteryEncounter.setDialogueToken("test", "value");
scene.currentBattle.mysteryEncounter.setDialogueToken("testvalue", "new");
const result = getTextWithEncounterDialogueTokens(scene, "mysteryEncounter:unit_test_dialogue");
expect(result).toEqual("valuevalue new @ec{test1} value @ec{test\\} @ec{test\\} {test}");
});
});
describe("queueEncounterMessage", () => {
it("queues a message with encounter dialogue tokens", async () => {
scene.currentBattle.mysteryEncounter = new MysteryEncounter(null);
scene.currentBattle.mysteryEncounter.setDialogueToken("test", "value");
const spy = vi.spyOn(game.scene, "queueMessage");
const phaseSpy = vi.spyOn(game.scene, "unshiftPhase");
queueEncounterMessage(scene, "mysteryEncounter:unit_test_dialogue");
expect(spy).toHaveBeenCalledWith("valuevalue @ec{testvalue} @ec{test1} value @ec{test\\} @ec{test\\} {test}", null, true);
expect(phaseSpy).toHaveBeenCalledWith(expect.any(MessagePhase));
});
});
describe("showEncounterText", () => {
it("showText with dialogue tokens", async () => {
scene.currentBattle.mysteryEncounter = new MysteryEncounter(null);
scene.currentBattle.mysteryEncounter.setDialogueToken("test", "value");
const spy = vi.spyOn(game.scene.ui, "showText");
showEncounterText(scene, "mysteryEncounter:unit_test_dialogue");
expect(spy).toHaveBeenCalledWith("valuevalue @ec{testvalue} @ec{test1} value @ec{test\\} @ec{test\\} {test}", null, expect.any(Function), 0, true);
});
});
describe("showEncounterDialogue", () => {
it("showText with dialogue tokens", async () => {
scene.currentBattle.mysteryEncounter = new MysteryEncounter(null);
scene.currentBattle.mysteryEncounter.setDialogueToken("test", "value");
const spy = vi.spyOn(game.scene.ui, "showDialogue");
showEncounterDialogue(scene, "mysteryEncounter:unit_test_dialogue", "mysteryEncounter:unit_test_dialogue");
expect(spy).toHaveBeenCalledWith("valuevalue @ec{testvalue} @ec{test1} value @ec{test\\} @ec{test\\} {test}", "valuevalue @ec{testvalue} @ec{test1} value @ec{test\\} @ec{test\\} {test}", null, undefined, 0, 0);
});
});
describe("initBattleWithEnemyConfig", () => {
it("", () => {
});
});
describe("setCustomEncounterRewards", () => {
it("", () => {
});
});
describe("selectPokemonForOption", () => {
it("", () => {
});
});
describe("setEncounterExp", () => {
it("", () => {
});
});
describe("leaveEncounterWithoutBattle", () => {
it("", () => {
});
});
describe("handleMysteryEncounterVictory", () => {
it("", () => {
});
});
});

View File

@ -1,9 +1,11 @@
import {afterEach, beforeAll, beforeEach, describe, it, vi} from "vitest";
import {afterEach, beforeAll, beforeEach, expect, describe, it, vi} from "vitest";
import * as overrides from "../../overrides";
import GameManager from "#app/test/utils/gameManager";
import Phaser from "phaser";
import {Species} from "#enums/species";
import {MysteryEncounterPhase} from "#app/phases/mystery-encounter-phase";
describe("Mystery Encounter", () => {
describe("Mystery Encounters", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
@ -19,17 +21,29 @@ describe("Mystery Encounter", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
vi.spyOn(overrides, "MYSTERY_ENCOUNTER_RATE_OVERRIDE", "get").mockReturnValue(64);
vi.spyOn(overrides, "STARTING_WAVE_OVERRIDE", "get").mockReturnValue(3);
vi.spyOn(overrides, "MYSTERY_ENCOUNTER_RATE_OVERRIDE", "get").mockReturnValue(256);
vi.spyOn(overrides, "STARTING_WAVE_OVERRIDE", "get").mockReturnValue(11);
});
it("spawns a mystery encounter", async() => {
// await game.runToSummon([
// Species.CHARIZARD,
// Species.VOLCARONA
// ]);
// expect(game.scene.getCurrentPhase().constructor.name).toBe(EncounterPhase.name);
}, 100000);
it("Spawns a mystery encounter", async() => {
await game.runToMysteryEncounter([
Species.CHARIZARD,
Species.VOLCARONA
]);
await game.phaseInterceptor.to(MysteryEncounterPhase, false);
expect(game.scene.getCurrentPhase().constructor.name).toBe(MysteryEncounterPhase.name);
});
it("", async() => {
await game.runToMysteryEncounter([
Species.CHARIZARD,
Species.VOLCARONA
]);
await game.phaseInterceptor.to(MysteryEncounterPhase, false);
expect(game.scene.getCurrentPhase().constructor.name).toBe(MysteryEncounterPhase.name);
});
it("spawns mysterious challengers encounter", async() => {
});

View File

@ -0,0 +1,159 @@
import {afterEach, beforeAll, beforeEach, expect, describe, it, vi} from "vitest";
import * as overrides from "../../overrides";
import GameManager from "#app/test/utils/gameManager";
import Phaser from "phaser";
import {Species} from "#enums/species";
import {MysteryEncounterOptionSelectedPhase, MysteryEncounterPhase} from "#app/phases/mystery-encounter-phase";
import {Mode} from "#app/ui/ui";
import {Button} from "#enums/buttons";
import MysteryEncounterUiHandler from "#app/ui/mystery-encounter-ui-handler";
import {MysteryEncounterType} from "#enums/mystery-encounter-type";
import {MysteryEncounterTier} from "#app/data/mystery-encounter";
describe("Mystery Encounter Phases", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
vi.spyOn(overrides, "MYSTERY_ENCOUNTER_RATE_OVERRIDE", "get").mockReturnValue(256);
vi.spyOn(overrides, "STARTING_WAVE_OVERRIDE", "get").mockReturnValue(11);
});
describe("MysteryEncounterPhase", () => {
it("Runs to MysteryEncounterPhase", async() => {
await game.runToMysteryEncounter([
Species.CHARIZARD,
Species.VOLCARONA
]);
await game.phaseInterceptor.to(MysteryEncounterPhase, false);
expect(game.scene.getCurrentPhase().constructor.name).toBe(MysteryEncounterPhase.name);
});
it("Runs MysteryEncounterPhase", async() => {
vi.spyOn(overrides, "MYSTERY_ENCOUNTER_OVERRIDE", "get").mockReturnValue(MysteryEncounterType.MYSTERIOUS_CHALLENGERS);
await game.runToMysteryEncounter([
Species.CHARIZARD,
Species.VOLCARONA
]);
game.onNextPrompt("MysteryEncounterPhase", Mode.MYSTERY_ENCOUNTER, () => {
// End phase early for test
game.phaseInterceptor.superEndPhase();
});
await game.phaseInterceptor.run(MysteryEncounterPhase);
expect(game.scene.mysteryEncounterData.encounteredEvents.length).toBeGreaterThan(0);
expect(game.scene.mysteryEncounterData.encounteredEvents[0][0]).toEqual(MysteryEncounterType.MYSTERIOUS_CHALLENGERS);
expect(game.scene.mysteryEncounterData.encounteredEvents[0][1]).toEqual(MysteryEncounterTier.UNCOMMON);
expect(game.scene.ui.getMode()).toBe(Mode.MYSTERY_ENCOUNTER);
});
it("Selects an option for MysteryEncounterPhase", async() => {
vi.spyOn(overrides, "MYSTERY_ENCOUNTER_OVERRIDE", "get").mockReturnValue(MysteryEncounterType.MYSTERIOUS_CHALLENGERS);
const dialogueSpy = vi.spyOn(game.scene.ui, "showDialogue");
const messageSpy = vi.spyOn(game.scene.ui, "showText");
await game.runToMysteryEncounter([
Species.CHARIZARD,
Species.VOLCARONA
]);
game.onNextPrompt("MysteryEncounterPhase", Mode.MYSTERY_ENCOUNTER, () => {
// Select option 1 for encounter
const handler = game.scene.ui.getHandler() as MysteryEncounterUiHandler;
handler.unblockInput();
handler.processInput(Button.ACTION);
});
await game.phaseInterceptor.run(MysteryEncounterPhase);
// After option selected
expect(game.scene.getCurrentPhase().constructor.name).toBe(MysteryEncounterOptionSelectedPhase.name);
expect(game.scene.ui.getMode()).toBe(Mode.MESSAGE);
expect(dialogueSpy).toHaveBeenCalledTimes(1);
expect(messageSpy).toHaveBeenCalledTimes(2);
expect(dialogueSpy).toHaveBeenCalledWith("What's this?", "???", null, expect.any(Function));
expect(messageSpy).toHaveBeenCalledWith("Mysterious challengers have appeared!", null, expect.any(Function), 750, true);
expect(messageSpy).toHaveBeenCalledWith("The trainer steps forward...", null, expect.any(Function), 750, true);
});
});
describe("MysteryEncounterOptionSelectedPhase", () => {
it("runs phase", () => {
});
it("handles onOptionSelect execution", () => {
});
it("hides intro visuals", () => {
});
it("does not hide intro visuals if option disabled", () => {
});
});
describe("MysteryEncounterBattlePhase", () => {
it("runs phase", () => {
});
it("handles TRAINER_BATTLE variant", () => {
});
it("handles BOSS_BATTLE variant", () => {
});
it("handles WILD_BATTLE variant", () => {
});
it("handles double battle", () => {
});
});
describe("MysteryEncounterRewardsPhase", () => {
it("runs phase", () => {
});
it("handles doEncounterRewards", () => {
});
it("handles heal phase if enabled", () => {
});
});
describe("PostMysteryEncounterPhase", () => {
it("runs phase", () => {
});
it("handles onPostOptionSelect execution", () => {
});
it("runs to next EncounterPhase", () => {
});
});
});

View File

@ -33,6 +33,7 @@ import { Species } from "#enums/species";
import { Button } from "#enums/buttons";
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";
/**
* Class to manage the game state and transitions between phases.
@ -137,6 +138,30 @@ export default class GameManager {
await this.phaseInterceptor.run(EncounterPhase);
}
/**
* Runs the game to a mystery encounter phase.
* @param species - Optional array of species for party.
* @returns A promise that resolves when the EncounterPhase ends.
*/
async runToMysteryEncounter(species?: Species[]) {
await this.runToTitle();
this.onNextPrompt("TitlePhase", Mode.TITLE, () => {
this.scene.gameMode = getGameMode(GameModes.CLASSIC);
const starters = generateStarter(this.scene, species);
const selectStarterPhase = new SelectStarterPhase(this.scene);
this.scene.pushPhase(new EncounterPhase(this.scene, false));
selectStarterPhase.initBattle(starters);
});
this.onNextPrompt("EncounterPhase", Mode.MESSAGE, () => {
const handler = this.scene.ui.getHandler() as BattleMessageUiHandler;
handler.processInput(Button.ACTION);
}, null, true);
await this.phaseInterceptor.run(EncounterPhase);
}
/**
* Transitions to the start of a battle.
* @param species - Optional array of species to start the battle with.

View File

@ -33,6 +33,10 @@ export default class MockContainer {
// same as remove or destroy
}
removeBetween(startIndex, endIndex, destroyChild) {
// Removes multiple children across an index range
}
addedToScene() {
// This callback is invoked when this Game Object is added to a Scene.
}

View File

@ -31,6 +31,7 @@ export default class MockSprite {
};
this.anims = {
pause: () => null,
stop: () => null,
};
}

View File

@ -103,7 +103,7 @@ export default class MysteryEncounterUiHandler extends UiHandler {
if (cursor === this.viewPartyIndex) {
// Handle view party
success = true;
this.clear();
// this.clear();
this.scene.ui.setMode(Mode.PARTY, PartyUiMode.CHECK, -1, () => {
this.scene.ui.setMode(Mode.MYSTERY_ENCOUNTER, true);
setTimeout(() => {
@ -116,7 +116,7 @@ export default class MysteryEncounterUiHandler extends UiHandler {
} else {
const selected = this.filteredEncounterOptions[cursor];
if ((this.scene.getCurrentPhase() as MysteryEncounterPhase).handleOptionSelect(selected, cursor)) {
this.clear();
// this.clear();
success = true;
} else {
ui.playError();