Merge branch 'beta' into test-cleanup-2

This commit is contained in:
NightKev 2025-08-04 21:10:15 -07:00
commit 0d40385aa4
61 changed files with 3497 additions and 2191 deletions

View File

@ -19,7 +19,6 @@
// and having to verify whether each individual file is ignored
"includes": [
"**",
"!**/*.d.ts",
"!**/dist/**/*",
"!**/build/**/*",
"!**/coverage/**/*",
@ -180,7 +179,7 @@
// Overrides to prevent unused import removal inside `overrides.ts` and enums files (for TSDoc linkcodes),
// as well as in all TS files in `scripts/` (which are assumed to be boilerplate templates).
{
"includes": ["**/src/overrides.ts", "**/src/enums/**/*", "**/scripts/**/*.ts"],
"includes": ["**/src/overrides.ts", "**/src/enums/**/*", "**/scripts/**/*.ts", "**/*.d.ts"],
"linter": {
"rules": {
"correctness": {

10
global.d.ts vendored
View File

@ -1,7 +1,6 @@
import type { AnyFn } from "#types/type-helpers";
import type { SetupServerApi } from "msw/node";
export {};
declare global {
/**
* Only used in testing.
@ -11,4 +10,11 @@ declare global {
* To set up your own server in a test see `game-data.test.ts`
*/
var server: SetupServerApi;
// Overloads for `Function.apply` and `Function.call` to add type safety on matching argument types
interface Function {
apply<T extends AnyFn>(this: T, thisArg: ThisParameterType<T>, argArray: Parameters<T>): ReturnType<T>;
call<T extends AnyFn>(this: T, thisArg: ThisParameterType<T>, ...argArray: Parameters<T>): ReturnType<T>;
}
}

View File

@ -94,3 +94,12 @@ export type AbstractConstructor<T> = abstract new (...args: any[]) => T;
export type CoerceNullPropertiesToUndefined<T extends object> = {
[K in keyof T]: null extends T[K] ? Exclude<T[K], null> | undefined : T[K];
};
/**
* Type helper to mark all properties in `T` as optional, while still mandating that at least 1
* of its properties be present.
*
* Distinct from {@linkcode Partial} as this requires at least 1 property to _not_ be undefined.
* @typeParam T - The type to render partial
*/
export type AtLeastOne<T extends object> = Partial<T> & ObjectValues<{ [K in keyof T]: Pick<Required<T>, K> }>;

View File

@ -380,9 +380,21 @@ export class BattleScene extends SceneBase {
};
}
populateAnims();
/**
* These moves serve as fallback animations for other moves without loaded animations, and
* must be loaded prior to game start.
*/
const defaultMoves = [MoveId.TACKLE, MoveId.TAIL_WHIP, MoveId.FOCUS_ENERGY, MoveId.STRUGGLE];
await this.initVariantData();
await Promise.all([
populateAnims(),
this.initVariantData(),
initCommonAnims().then(() => loadCommonAnimAssets(true)),
Promise.all(defaultMoves.map(m => initMoveAnim(m))).then(() => loadMoveAnimAssets(defaultMoves, true)),
this.initStarterColors(),
]).catch(reason => {
throw new Error(`Unexpected error during BattleScene preLoad!\nReason: ${reason}`);
});
}
create() {
@ -584,8 +596,6 @@ export class BattleScene extends SceneBase {
this.party = [];
const loadPokemonAssets = [];
this.arenaPlayer = new ArenaBase(true);
this.arenaPlayer.setName("arena-player");
this.arenaPlayerTransition = new ArenaBase(true);
@ -640,26 +650,14 @@ export class BattleScene extends SceneBase {
this.reset(false, false, true);
// Initialize UI-related aspects and then start the login phase.
const ui = new UI();
this.uiContainer.add(ui);
this.ui = ui;
ui.setup();
const defaultMoves = [MoveId.TACKLE, MoveId.TAIL_WHIP, MoveId.FOCUS_ENERGY, MoveId.STRUGGLE];
Promise.all([
Promise.all(loadPokemonAssets),
initCommonAnims().then(() => loadCommonAnimAssets(true)),
Promise.all(
[MoveId.TACKLE, MoveId.TAIL_WHIP, MoveId.FOCUS_ENERGY, MoveId.STRUGGLE].map(m => initMoveAnim(m)),
).then(() => loadMoveAnimAssets(defaultMoves, true)),
this.initStarterColors(),
]).then(() => {
this.phaseManager.toTitleScreen(true);
this.phaseManager.shiftPhase();
});
this.phaseManager.toTitleScreen(true);
this.phaseManager.shiftPhase();
}
initSession(): void {
@ -3530,6 +3528,7 @@ export class BattleScene extends SceneBase {
this.gameMode.hasMysteryEncounters &&
battleType === BattleType.WILD &&
!this.gameMode.isBoss(waveIndex) &&
waveIndex % 10 !== 1 &&
waveIndex < highestMysteryEncounterWave &&
waveIndex > lowestMysteryEncounterWave
);

View File

@ -1291,11 +1291,11 @@ export const biomePokemonPools: BiomePokemonPools = {
[TimeOfDay.ALL]: [ { 1: [ SpeciesId.BELDUM ], 20: [ SpeciesId.METANG ], 45: [ SpeciesId.METAGROSS ] }, SpeciesId.SIGILYPH, { 1: [ SpeciesId.SOLOSIS ], 32: [ SpeciesId.DUOSION ], 41: [ SpeciesId.REUNICLUS ] } ]
},
[BiomePoolTier.SUPER_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ { 1: [ SpeciesId.PORYGON ], 30: [ SpeciesId.PORYGON2 ] } ] },
[BiomePoolTier.ULTRA_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ { 1: [ SpeciesId.COSMOG ], 23: [ SpeciesId.COSMOEM ] }, SpeciesId.CELESTEELA ] },
[BiomePoolTier.ULTRA_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ { 1: [ SpeciesId.COSMOG ], 60: [ SpeciesId.COSMOEM ] }, SpeciesId.CELESTEELA ] },
[BiomePoolTier.BOSS]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [ SpeciesId.SOLROCK ], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [ SpeciesId.LUNATONE ], [TimeOfDay.ALL]: [ SpeciesId.CLEFABLE, SpeciesId.BRONZONG, SpeciesId.MUSHARNA, SpeciesId.REUNICLUS, SpeciesId.MINIOR ] },
[BiomePoolTier.BOSS_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.METAGROSS, SpeciesId.PORYGON_Z ] },
[BiomePoolTier.BOSS_SUPER_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [], [TimeOfDay.ALL]: [ SpeciesId.CELESTEELA ] },
[BiomePoolTier.BOSS_ULTRA_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [ SpeciesId.SOLGALEO ], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [ SpeciesId.LUNALA ], [TimeOfDay.ALL]: [ SpeciesId.RAYQUAZA, SpeciesId.NECROZMA ] }
[BiomePoolTier.BOSS_ULTRA_RARE]: { [TimeOfDay.DAWN]: [], [TimeOfDay.DAY]: [ { 1: [ SpeciesId.COSMOG ], 60: [ SpeciesId.COSMOEM ], 80: [ SpeciesId.SOLGALEO ] } ], [TimeOfDay.DUSK]: [], [TimeOfDay.NIGHT]: [ { 1: [ SpeciesId.COSMOG ], 60: [ SpeciesId.COSMOEM ], 80: [ SpeciesId.LUNALA ] } ], [TimeOfDay.ALL]: [ SpeciesId.RAYQUAZA, SpeciesId.NECROZMA ] }
},
[BiomeId.CONSTRUCTION_SITE]: {
[BiomePoolTier.COMMON]: {

View File

@ -1191,11 +1191,11 @@ export const pokemonEvolutions: PokemonEvolutions = {
new SpeciesEvolution(SpeciesId.KOMMO_O, 45, null, null)
],
[SpeciesId.COSMOG]: [
new SpeciesEvolution(SpeciesId.COSMOEM, 23, null, null)
new SpeciesEvolution(SpeciesId.COSMOEM, 1, null, {key: EvoCondKey.FRIENDSHIP, value: 43}, SpeciesWildEvolutionDelay.VERY_LONG)
],
[SpeciesId.COSMOEM]: [
new SpeciesEvolution(SpeciesId.SOLGALEO, 1, EvolutionItem.SUN_FLUTE, null, SpeciesWildEvolutionDelay.VERY_LONG),
new SpeciesEvolution(SpeciesId.LUNALA, 1, EvolutionItem.MOON_FLUTE, null, SpeciesWildEvolutionDelay.VERY_LONG)
new SpeciesEvolution(SpeciesId.SOLGALEO, 13, EvolutionItem.SUN_FLUTE, null, SpeciesWildEvolutionDelay.VERY_LONG),
new SpeciesEvolution(SpeciesId.LUNALA, 13, EvolutionItem.MOON_FLUTE, null, SpeciesWildEvolutionDelay.VERY_LONG)
],
[SpeciesId.MELTAN]: [
new SpeciesEvolution(SpeciesId.MELMETAL, 48, null, null)
@ -1824,7 +1824,7 @@ export const pokemonEvolutions: PokemonEvolutions = {
new SpeciesEvolution(SpeciesId.ROSELIA, 1, null, [{key: EvoCondKey.FRIENDSHIP, value: 70}, {key: EvoCondKey.TIME, time: [TimeOfDay.DAWN, TimeOfDay.DAY]}], SpeciesWildEvolutionDelay.SHORT)
],
[SpeciesId.BUNEARY]: [
new SpeciesEvolution(SpeciesId.LOPUNNY, 1, null, {key: EvoCondKey.FRIENDSHIP, value: 70}, SpeciesWildEvolutionDelay.MEDIUM)
new SpeciesEvolution(SpeciesId.LOPUNNY, 1, null, {key: EvoCondKey.FRIENDSHIP, value: 50}, SpeciesWildEvolutionDelay.MEDIUM)
],
[SpeciesId.CHINGLING]: [
new SpeciesEvolution(SpeciesId.CHIMECHO, 1, null, [{key: EvoCondKey.FRIENDSHIP, value: 90}, {key: EvoCondKey.TIME, time: [TimeOfDay.DUSK, TimeOfDay.NIGHT]}], SpeciesWildEvolutionDelay.MEDIUM)

File diff suppressed because it is too large Load Diff

View File

@ -3560,6 +3560,7 @@ export class GrudgeTag extends SerializableBattlerTag {
* @param sourcePokemon - The source of the move that fainted the tag's bearer
* @returns `false` if Grudge activates its effect or lapses
*/
// TODO: Confirm whether this should interact with copying moves
override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType, sourcePokemon?: Pokemon): boolean {
if (lapseType === BattlerTagLapseType.CUSTOM && sourcePokemon) {
if (sourcePokemon.isActive() && pokemon.isOpponent(sourcePokemon)) {

View File

@ -4,15 +4,12 @@ import { defaultStarterSpecies } from "#app/constants";
import { globalScene } from "#app/global-scene";
import { pokemonEvolutions } from "#balance/pokemon-evolutions";
import { speciesStarterCosts } from "#balance/starters";
import { getEggTierForSpecies } from "#data/egg";
import { pokemonFormChanges } from "#data/pokemon-forms";
import type { PokemonSpecies } from "#data/pokemon-species";
import { getPokemonSpeciesForm } from "#data/pokemon-species";
import { BattleType } from "#enums/battle-type";
import { ChallengeType } from "#enums/challenge-type";
import { Challenges } from "#enums/challenges";
import { TypeColor, TypeShadow } from "#enums/color";
import { EggTier } from "#enums/egg-type";
import { ClassicFixedBossWaves } from "#enums/fixed-boss-waves";
import { ModifierTier } from "#enums/modifier-tier";
import type { MoveId } from "#enums/move-id";
@ -26,9 +23,9 @@ import type { Pokemon } from "#field/pokemon";
import { Trainer } from "#field/trainer";
import { PokemonMove } from "#moves/pokemon-move";
import type { DexAttrProps, GameData } from "#system/game-data";
import { BooleanHolder, type NumberHolder, randSeedItem } from "#utils/common";
import { BooleanHolder, isBetween, type NumberHolder, randSeedItem } from "#utils/common";
import { deepCopy } from "#utils/data";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils";
import { toCamelCase, toSnakeCase } from "#utils/strings";
import i18next from "i18next";
@ -685,14 +682,11 @@ export class SingleTypeChallenge extends Challenge {
*/
export class FreshStartChallenge extends Challenge {
constructor() {
super(Challenges.FRESH_START, 3);
super(Challenges.FRESH_START, 2);
}
applyStarterChoice(pokemon: PokemonSpecies, valid: BooleanHolder): boolean {
if (
(this.value === 1 && !defaultStarterSpecies.includes(pokemon.speciesId)) ||
(this.value === 2 && getEggTierForSpecies(pokemon) >= EggTier.EPIC)
) {
if (this.value === 1 && !defaultStarterSpecies.includes(pokemon.speciesId)) {
valid.value = false;
return true;
}
@ -708,12 +702,18 @@ export class FreshStartChallenge extends Challenge {
pokemon.abilityIndex = pokemon.abilityIndex % 2; // Always base ability, if you set it to hidden it wraps to first ability
pokemon.passive = false; // Passive isn't unlocked
pokemon.nature = Nature.HARDY; // Neutral nature
pokemon.moveset = pokemon.species
let validMoves = pokemon.species
.getLevelMoves()
.filter(m => m[0] <= 5)
.map(lm => lm[1])
.slice(0, 4)
.map(m => new PokemonMove(m)); // No egg moves
.filter(m => isBetween(m[0], 1, 5))
.map(lm => lm[1]);
// Filter egg moves out of the moveset
pokemon.moveset = pokemon.moveset.filter(pm => validMoves.includes(pm.moveId));
if (pokemon.moveset.length < 4) {
// If there's empty slots fill with remaining valid moves
const existingMoveIds = pokemon.moveset.map(pm => pm.moveId);
validMoves = validMoves.filter(m => !existingMoveIds.includes(m));
pokemon.moveset = pokemon.moveset.concat(validMoves.map(m => new PokemonMove(m))).slice(0, 4);
}
pokemon.luck = 0; // No luck
pokemon.shiny = false; // Not shiny
pokemon.variant = 0; // Not shiny

View File

@ -2,7 +2,7 @@ import { pokerogueApi } from "#api/pokerogue-api";
import { globalScene } from "#app/global-scene";
import { speciesStarterCosts } from "#balance/starters";
import type { PokemonSpeciesForm } from "#data/pokemon-species";
import { getPokemonSpeciesForm, PokemonSpecies } from "#data/pokemon-species";
import { PokemonSpecies } from "#data/pokemon-species";
import { BiomeId } from "#enums/biome-id";
import { PartyMemberStrength } from "#enums/party-member-strength";
import type { SpeciesId } from "#enums/species-id";
@ -10,7 +10,7 @@ import { PlayerPokemon } from "#field/pokemon";
import type { Starter } from "#ui/starter-select-ui-handler";
import { randSeedGauss, randSeedInt, randSeedItem } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils";
export interface DailyRunConfig {
seed: number;

View File

@ -59,7 +59,7 @@ export const TeleportingHijinksEncounter: MysteryEncounter = MysteryEncounterBui
)
.withEncounterTier(MysteryEncounterTier.COMMON)
.withSceneWaveRangeRequirement(...CLASSIC_MODE_MYSTERY_ENCOUNTER_WAVES)
.withSceneRequirement(new WaveModulusRequirement([1, 2, 3], 10)) // Must be in first 3 waves after boss wave
.withSceneRequirement(new WaveModulusRequirement([2, 3, 4], 10)) // Must be in first 3 waves after boss wave
.withSceneRequirement(new MoneyRequirement(0, MONEY_COST_MULTIPLIER)) // Must be able to pay teleport cost
.withAutoHideIntroVisuals(false)
.withCatchAllowed(true)

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ import { loadBattlerTag, SerializableBattlerTag } from "#data/battler-tags";
import { allSpecies } from "#data/data-lists";
import type { Gender } from "#data/gender";
import { PokemonMove } from "#data/moves/pokemon-move";
import { getPokemonSpeciesForm, type PokemonSpeciesForm } from "#data/pokemon-species";
import type { PokemonSpeciesForm } from "#data/pokemon-species";
import type { TypeDamageMultiplier } from "#data/type";
import type { AbilityId } from "#enums/ability-id";
import type { BerryType } from "#enums/berry-type";
@ -16,6 +16,7 @@ import type { IllusionData } from "#types/illusion-data";
import type { TurnMove } from "#types/turn-move";
import type { CoerceNullPropertiesToUndefined } from "#types/type-helpers";
import { isNullOrUndefined } from "#utils/common";
import { getPokemonSpeciesForm } from "#utils/pokemon-utils";
/**
* The type that {@linkcode PokemonSpeciesForm} is converted to when an object containing it serializes it.
@ -161,6 +162,7 @@ export class PokemonSummonData {
if (key === "speciesForm" || key === "fusionSpeciesForm") {
this[key] = deserializePokemonSpeciesForm(value);
continue;
}
if (key === "illusion" && typeof value === "object") {
@ -181,6 +183,7 @@ export class PokemonSummonData {
}
}
this[key] = illusionData as IllusionData;
continue;
}
if (key === "moveset") {

View File

@ -1244,12 +1244,58 @@ export const trainerConfigs: TrainerConfigs = {
.setHasDouble("Breeders")
.setPartyTemplateFunc(() =>
getWavePartyTemplate(
trainerPartyTemplates.FOUR_WEAKER,
trainerPartyTemplates.FIVE_WEAKER,
trainerPartyTemplates.SIX_WEAKER,
trainerPartyTemplates.FOUR_WEAK,
trainerPartyTemplates.FIVE_WEAK,
trainerPartyTemplates.SIX_WEAK,
),
)
.setSpeciesFilter(s => s.baseTotal < 450),
.setSpeciesPools({
[TrainerPoolTier.COMMON]: [
SpeciesId.PICHU,
SpeciesId.CLEFFA,
SpeciesId.IGGLYBUFF,
SpeciesId.TOGEPI,
SpeciesId.TYROGUE,
SpeciesId.SMOOCHUM,
SpeciesId.AZURILL,
SpeciesId.BUDEW,
SpeciesId.CHINGLING,
SpeciesId.BONSLY,
SpeciesId.MIME_JR,
SpeciesId.HAPPINY,
SpeciesId.MANTYKE,
SpeciesId.TOXEL,
],
[TrainerPoolTier.UNCOMMON]: [
SpeciesId.DITTO,
SpeciesId.ELEKID,
SpeciesId.MAGBY,
SpeciesId.WYNAUT,
SpeciesId.MUNCHLAX,
SpeciesId.RIOLU,
SpeciesId.AUDINO,
],
[TrainerPoolTier.RARE]: [
SpeciesId.ALOLA_RATTATA,
SpeciesId.ALOLA_SANDSHREW,
SpeciesId.ALOLA_VULPIX,
SpeciesId.ALOLA_DIGLETT,
SpeciesId.ALOLA_MEOWTH,
SpeciesId.GALAR_PONYTA,
],
[TrainerPoolTier.SUPER_RARE]: [
SpeciesId.ALOLA_GEODUDE,
SpeciesId.ALOLA_GRIMER,
SpeciesId.GALAR_MEOWTH,
SpeciesId.GALAR_SLOWPOKE,
SpeciesId.GALAR_FARFETCHD,
SpeciesId.HISUI_GROWLITHE,
SpeciesId.HISUI_VOLTORB,
SpeciesId.HISUI_QWILFISH,
SpeciesId.HISUI_SNEASEL,
SpeciesId.HISUI_ZORUA,
],
}),
[TrainerType.CLERK]: new TrainerConfig(++t)
.setHasGenders("Clerk Female")
.setHasDouble("Colleagues")

View File

@ -144,6 +144,7 @@ export const trainerPartyTemplates = {
FIVE_WEAK_BALANCED: new TrainerPartyTemplate(5, PartyMemberStrength.WEAK, false, true),
SIX_WEAKER: new TrainerPartyTemplate(6, PartyMemberStrength.WEAKER),
SIX_WEAKER_SAME: new TrainerPartyTemplate(6, PartyMemberStrength.WEAKER, true),
SIX_WEAK: new TrainerPartyTemplate(6, PartyMemberStrength.WEAK),
SIX_WEAK_SAME: new TrainerPartyTemplate(6, PartyMemberStrength.WEAK, true),
SIX_WEAK_BALANCED: new TrainerPartyTemplate(6, PartyMemberStrength.WEAK, false, true),

View File

@ -60,7 +60,7 @@ import {
} from "#data/pokemon-data";
import type { SpeciesFormChange } from "#data/pokemon-forms";
import type { PokemonSpeciesForm } from "#data/pokemon-species";
import { getFusedSpeciesName, getPokemonSpeciesForm, PokemonSpecies } from "#data/pokemon-species";
import { PokemonSpecies } from "#data/pokemon-species";
import { getRandomStatus, getStatusEffectOverlapText, Status } from "#data/status-effect";
import { getTerrainBlockMessage, TerrainType } from "#data/terrain";
import type { TypeDamageMultiplier } from "#data/type";
@ -168,7 +168,7 @@ import {
toDmgValue,
} from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { getFusedSpeciesName, getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils";
import { argbFromRgba, QuantizerCelebi, rgbaFromArgb } from "@material/material-color-utilities";
import i18next from "i18next";
import Phaser from "phaser";
@ -1821,8 +1821,6 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
* @returns An array of {@linkcode PokemonMove}, as described above.
*/
getMoveset(ignoreOverride = false): PokemonMove[] {
const ret = !ignoreOverride && this.summonData.moveset ? this.summonData.moveset : this.moveset;
// Overrides moveset based on arrays specified in overrides.ts
let overrideArray: MoveId | Array<MoveId> = this.isPlayer()
? Overrides.MOVESET_OVERRIDE
@ -1838,7 +1836,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container {
});
}
return ret;
return !ignoreOverride && this.summonData.moveset ? this.summonData.moveset : this.moveset;
}
/**

View File

@ -2,10 +2,10 @@ import { initAbilities } from "#abilities/ability";
import { initBiomes } from "#balance/biomes";
import { initEggMoves } from "#balance/egg-moves";
import { initPokemonPrevolutions, initPokemonStarters } from "#balance/pokemon-evolutions";
import { initSpecies } from "#balance/pokemon-species";
import { initChallenges } from "#data/challenge";
import { initTrainerTypeDialogue } from "#data/dialogue";
import { initPokemonForms } from "#data/pokemon-forms";
import { initSpecies } from "#data/pokemon-species";
import { initModifierPools } from "#modifiers/init-modifier-pools";
import { initModifierTypes } from "#modifiers/modifier-type";
import { initMoves } from "#moves/move";

View File

@ -99,8 +99,12 @@ export class SelectStarterPhase extends Phase {
starterPokemon.generateFusionSpecies(true);
}
starterPokemon.setVisible(false);
applyChallenges(ChallengeType.STARTER_MODIFY, starterPokemon);
const chalApplied = applyChallenges(ChallengeType.STARTER_MODIFY, starterPokemon);
party.push(starterPokemon);
if (chalApplied) {
// If any challenges modified the starter, it should update
loadPokemonAssets.push(starterPokemon.updateInfo());
}
loadPokemonAssets.push(starterPokemon.loadAssets());
});
overrideModifiers();

View File

@ -1,7 +1,6 @@
import { globalScene } from "#app/global-scene";
import type { Gender } from "#data/gender";
import { CustomPokemonData, PokemonBattleData, PokemonSummonData } from "#data/pokemon-data";
import { getPokemonSpeciesForm } from "#data/pokemon-species";
import { Status } from "#data/status-effect";
import { BattleType } from "#enums/battle-type";
import type { BiomeId } from "#enums/biome-id";
@ -14,7 +13,7 @@ import { TrainerSlot } from "#enums/trainer-slot";
import { EnemyPokemon, Pokemon } from "#field/pokemon";
import { PokemonMove } from "#moves/pokemon-move";
import type { Variant } from "#sprites/variant";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils";
export class PokemonData {
public id: number;

View File

@ -1,11 +1,10 @@
import { globalScene } from "#app/global-scene";
import { getPokemonSpeciesForm } from "#data/pokemon-species";
import { DexAttr } from "#enums/dex-attr";
import type { SessionSaveData, SystemSaveData } from "#system/game-data";
import type { SessionSaveMigrator } from "#types/session-save-migrator";
import type { SystemSaveMigrator } from "#types/system-save-migrator";
import { isNullOrUndefined } from "#utils/common";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils";
/**
* If a starter is caught, but the only forms registered as caught are not starterSelectable,

View File

@ -3,6 +3,7 @@ import type { TOptions } from "i18next";
// Module declared to make referencing keys in the localization files type-safe.
declare module "i18next" {
interface TFunction {
// biome-ignore lint/style/useShorthandFunctionType: This needs to be an interface due to interface merging
(key: string | string[], options?: TOptions & Record<string, unknown>): string;
}
}

View File

@ -1,7 +1,7 @@
import "phaser";
declare module "phaser" {
namespace GameObjects {
namespace GameObjects {
interface GameObject {
width: number;
@ -16,45 +16,45 @@ declare module "phaser" {
y: number;
}
interface Container {
interface Container {
/**
* Sets this object's position relative to another object with a given offset
*/
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Sprite {
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Sprite {
/**
* Sets this object's position relative to another object with a given offset
*/
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Image {
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Image {
/**
* Sets this object's position relative to another object with a given offset
*/
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface NineSlice {
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface NineSlice {
/**
* Sets this object's position relative to another object with a given offset
*/
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Text {
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Text {
/**
* Sets this object's position relative to another object with a given offset
*/
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Rectangle {
setPositionRelative(guideObject: any, x: number, y: number): this;
}
interface Rectangle {
/**
* Sets this object's position relative to another object with a given offset
*/
setPositionRelative(guideObject: any, x: number, y: number): this;
}
}
setPositionRelative(guideObject: any, x: number, y: number): this;
}
}
namespace Input {
namespace Input {
namespace Gamepad {
interface GamepadPlugin {
/**

View File

@ -827,6 +827,11 @@ export class PartyUiHandler extends MessageUiHandler {
globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeItemTrigger, false, true);
}
// This is processed before the filter result since releasing does not depend on status.
if (option === PartyOption.RELEASE) {
return this.processReleaseOption(pokemon);
}
// If the pokemon is filtered out for this option, we cannot continue
const filterResult = this.getFilterResult(option, pokemon);
if (filterResult) {
@ -850,10 +855,6 @@ export class PartyUiHandler extends MessageUiHandler {
// PartyUiMode.POST_BATTLE_SWITCH (SEND_OUT)
// These are the options that need a callback
if (option === PartyOption.RELEASE) {
return this.processReleaseOption(pokemon);
}
if (this.partyUiMode === PartyUiMode.SPLICE) {
if (option === PartyOption.SPLICE) {
(this.selectCallback as PartyModifierSpliceSelectCallback)(this.transferCursor, this.cursor);

View File

@ -25,7 +25,7 @@ import { getNatureName } from "#data/nature";
import type { SpeciesFormChange } from "#data/pokemon-forms";
import { pokemonFormChanges } from "#data/pokemon-forms";
import type { PokemonSpecies } from "#data/pokemon-species";
import { getPokemonSpeciesForm, normalForm } from "#data/pokemon-species";
import { normalForm } from "#data/pokemon-species";
import { AbilityAttr } from "#enums/ability-attr";
import type { AbilityId } from "#enums/ability-id";
import { BiomeId } from "#enums/biome-id";
@ -56,7 +56,7 @@ import { addBBCodeTextObject, addTextObject, getTextColor, getTextStyleOptions }
import { addWindow } from "#ui/ui-theme";
import { BooleanHolder, getLocalizedSpriteKey, isNullOrUndefined, padInt, rgbHexToRgba } from "#utils/common";
import { getEnumValues } from "#utils/enums";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils";
import { toTitleCase } from "#utils/strings";
import { argbFromRgba } from "@material/material-color-utilities";
import i18next from "i18next";

View File

@ -15,7 +15,7 @@ import {
import { speciesTmMoves } from "#balance/tms";
import { allAbilities, allMoves, allSpecies } from "#data/data-lists";
import type { PokemonForm, PokemonSpecies } from "#data/pokemon-species";
import { getPokemonSpeciesForm, getPokerusStarters, normalForm } from "#data/pokemon-species";
import { normalForm } from "#data/pokemon-species";
import { AbilityAttr } from "#enums/ability-attr";
import { AbilityId } from "#enums/ability-id";
import { BiomeId } from "#enums/biome-id";
@ -46,6 +46,7 @@ import { addWindow } from "#ui/ui-theme";
import { BooleanHolder, fixedInt, getLocalizedSpriteKey, padInt, randIntRange, rgbHexToRgba } from "#utils/common";
import type { StarterPreferences } from "#utils/data";
import { loadStarterPreferences } from "#utils/data";
import { getPokemonSpeciesForm, getPokerusStarters } from "#utils/pokemon-utils";
import { argbFromRgba } from "@material/material-color-utilities";
import i18next from "i18next";

View File

@ -5,7 +5,6 @@ import { allMoves } from "#data/data-lists";
import { getEggTierForSpecies } from "#data/egg";
import type { EggHatchData } from "#data/egg-hatch-data";
import { Gender } from "#data/gender";
import { getPokemonSpeciesForm } from "#data/pokemon-species";
import { PokemonType } from "#enums/pokemon-type";
import { SpeciesId } from "#enums/species-id";
import { TextStyle } from "#enums/text-style";
@ -13,6 +12,7 @@ import type { PlayerPokemon } from "#field/pokemon";
import { PokemonInfoContainer } from "#ui/pokemon-info-container";
import { addTextObject } from "#ui/text";
import { padInt, rgbHexToRgba } from "#utils/common";
import { getPokemonSpeciesForm } from "#utils/pokemon-utils";
import { argbFromRgba } from "@material/material-color-utilities";
/**

View File

@ -24,7 +24,6 @@ import { Gender, getGenderColor, getGenderSymbol } from "#data/gender";
import { getNatureName } from "#data/nature";
import { pokemonFormChanges } from "#data/pokemon-forms";
import type { PokemonSpecies } from "#data/pokemon-species";
import { getPokemonSpeciesForm, getPokerusStarters } from "#data/pokemon-species";
import { AbilityAttr } from "#enums/ability-attr";
import { AbilityId } from "#enums/ability-id";
import { Button } from "#enums/buttons";
@ -72,6 +71,7 @@ import {
} from "#utils/common";
import type { StarterPreferences } from "#utils/data";
import { loadStarterPreferences, saveStarterPreferences } from "#utils/data";
import { getPokemonSpeciesForm, getPokerusStarters } from "#utils/pokemon-utils";
import { toTitleCase } from "#utils/strings";
import { argbFromRgba } from "@material/material-color-utilities";
import i18next from "i18next";

View File

@ -1,6 +1,9 @@
import { globalScene } from "#app/global-scene";
import { POKERUS_STARTER_COUNT, speciesStarterCosts } from "#balance/starters";
import { allSpecies } from "#data/data-lists";
import type { PokemonSpecies } from "#data/pokemon-species";
import type { PokemonSpecies, PokemonSpeciesForm } from "#data/pokemon-species";
import type { SpeciesId } from "#enums/species-id";
import { randSeedItem } from "./common";
/**
* Gets the {@linkcode PokemonSpecies} object associated with the {@linkcode SpeciesId} enum given
@ -19,3 +22,104 @@ export function getPokemonSpecies(species: SpeciesId | SpeciesId[]): PokemonSpec
}
return allSpecies[species - 1];
}
/**
* Method to get the daily list of starters with Pokerus.
* @returns A list of starters with Pokerus
*/
export function getPokerusStarters(): PokemonSpecies[] {
const pokerusStarters: PokemonSpecies[] = [];
const date = new Date();
date.setUTCHours(0, 0, 0, 0);
globalScene.executeWithSeedOffset(
() => {
while (pokerusStarters.length < POKERUS_STARTER_COUNT) {
const randomSpeciesId = Number.parseInt(randSeedItem(Object.keys(speciesStarterCosts)), 10);
const species = getPokemonSpecies(randomSpeciesId);
if (!pokerusStarters.includes(species)) {
pokerusStarters.push(species);
}
}
},
0,
date.getTime().toString(),
);
return pokerusStarters;
}
export function getFusedSpeciesName(speciesAName: string, speciesBName: string): string {
const fragAPattern = /([a-z]{2}.*?[aeiou(?:y$)\-']+)(.*?)$/i;
const fragBPattern = /([a-z]{2}.*?[aeiou(?:y$)\-'])(.*?)$/i;
const [speciesAPrefixMatch, speciesBPrefixMatch] = [speciesAName, speciesBName].map(n => /^(?:[^ ]+) /.exec(n));
const [speciesAPrefix, speciesBPrefix] = [speciesAPrefixMatch, speciesBPrefixMatch].map(m => (m ? m[0] : ""));
if (speciesAPrefix) {
speciesAName = speciesAName.slice(speciesAPrefix.length);
}
if (speciesBPrefix) {
speciesBName = speciesBName.slice(speciesBPrefix.length);
}
const [speciesASuffixMatch, speciesBSuffixMatch] = [speciesAName, speciesBName].map(n => / (?:[^ ]+)$/.exec(n));
const [speciesASuffix, speciesBSuffix] = [speciesASuffixMatch, speciesBSuffixMatch].map(m => (m ? m[0] : ""));
if (speciesASuffix) {
speciesAName = speciesAName.slice(0, -speciesASuffix.length);
}
if (speciesBSuffix) {
speciesBName = speciesBName.slice(0, -speciesBSuffix.length);
}
const splitNameA = speciesAName.split(/ /g);
const splitNameB = speciesBName.split(/ /g);
const fragAMatch = fragAPattern.exec(speciesAName);
const fragBMatch = fragBPattern.exec(speciesBName);
let fragA: string;
let fragB: string;
fragA = splitNameA.length === 1 ? (fragAMatch ? fragAMatch[1] : speciesAName) : splitNameA[splitNameA.length - 1];
if (splitNameB.length === 1) {
if (fragBMatch) {
const lastCharA = fragA.slice(fragA.length - 1);
const prevCharB = fragBMatch[1].slice(fragBMatch.length - 1);
fragB = (/[-']/.test(prevCharB) ? prevCharB : "") + fragBMatch[2] || prevCharB;
if (lastCharA === fragB[0]) {
if (/[aiu]/.test(lastCharA)) {
fragB = fragB.slice(1);
} else {
const newCharMatch = new RegExp(`[^${lastCharA}]`).exec(fragB);
if (newCharMatch?.index !== undefined && newCharMatch.index > 0) {
fragB = fragB.slice(newCharMatch.index);
}
}
}
} else {
fragB = speciesBName;
}
} else {
fragB = splitNameB[splitNameB.length - 1];
}
if (splitNameA.length > 1) {
fragA = `${splitNameA.slice(0, splitNameA.length - 1).join(" ")} ${fragA}`;
}
fragB = `${fragB.slice(0, 1).toLowerCase()}${fragB.slice(1)}`;
return `${speciesAPrefix || speciesBPrefix}${fragA}${fragB}${speciesBSuffix || speciesASuffix}`;
}
export function getPokemonSpeciesForm(species: SpeciesId, formIndex: number): PokemonSpeciesForm {
const retSpecies: PokemonSpecies =
species >= 2000
? allSpecies.find(s => s.speciesId === species)! // TODO: is the bang correct?
: allSpecies[species - 1];
if (formIndex < retSpecies.forms?.length) {
return retSpecies.forms[formIndex];
}
return retSpecies;
}

18
src/vite.env.d.ts vendored
View File

@ -1,16 +1,16 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_PORT?: string;
readonly VITE_BYPASS_LOGIN?: string;
readonly VITE_BYPASS_TUTORIAL?: string;
readonly VITE_API_BASE_URL?: string;
readonly VITE_SERVER_URL?: string;
readonly VITE_DISCORD_CLIENT_ID?: string;
readonly VITE_GOOGLE_CLIENT_ID?: string;
readonly VITE_I18N_DEBUG?: string;
readonly VITE_PORT?: string;
readonly VITE_BYPASS_LOGIN?: string;
readonly VITE_BYPASS_TUTORIAL?: string;
readonly VITE_API_BASE_URL?: string;
readonly VITE_SERVER_URL?: string;
readonly VITE_DISCORD_CLIENT_ID?: string;
readonly VITE_GOOGLE_CLIENT_ID?: string;
readonly VITE_I18N_DEBUG?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv
readonly env: ImportMetaEnv;
}

View File

@ -1,26 +1,139 @@
import type { Pokemon } from "#field/pokemon";
import type { TerrainType } from "#app/data/terrain";
import type { AbilityId } from "#enums/ability-id";
import type { BattlerTagType } from "#enums/battler-tag-type";
import type { MoveId } from "#enums/move-id";
import type { PokemonType } from "#enums/pokemon-type";
import type { expect } from "vitest";
import type { BattleStat, EffectiveStat, Stat } from "#enums/stat";
import type { StatusEffect } from "#enums/status-effect";
import type { WeatherType } from "#enums/weather-type";
import type { Pokemon } from "#field/pokemon";
import type { ToHaveEffectiveStatMatcherOptions } from "#test/test-utils/matchers/to-have-effective-stat";
import type { expectedStatusType } from "#test/test-utils/matchers/to-have-status-effect";
import type { toHaveTypesOptions } from "#test/test-utils/matchers/to-have-types";
import type { TurnMove } from "#types/turn-move";
import type { AtLeastOne } from "#types/type-helpers";
import type { expect } from "vitest";
import type Overrides from "#app/overrides";
import type { PokemonMove } from "#moves/pokemon-move";
declare module "vitest" {
interface Assertion {
/**
* Matcher to check if an array contains EXACTLY the given items (in any order).
* Check whether an array contains EXACTLY the given items (in any order).
*
* Different from {@linkcode expect.arrayContaining} as the latter only requires the array contain
* _at least_ the listed items.
* Different from {@linkcode expect.arrayContaining} as the latter only checks for subset equality
* (as opposed to full equality).
*
* @param expected - The expected contents of the array, in any order.
* @param expected - The expected contents of the array, in any order
* @see {@linkcode expect.arrayContaining}
*/
toEqualArrayUnsorted<E>(expected: E[]): void;
/**
* Matcher to check if a {@linkcode Pokemon}'s current typing includes the given types.
* Check whether a {@linkcode Pokemon}'s current typing includes the given types.
*
* @param expected - The expected types (in any order).
* @param options - The options passed to the matcher.
* @param expected - The expected types (in any order)
* @param options - The options passed to the matcher
*/
toHaveTypes(expected: PokemonType[], options?: toHaveTypesOptions): void;
toHaveTypes(expected: [PokemonType, ...PokemonType[]], options?: toHaveTypesOptions): void;
/**
* Matcher to check the contents of a {@linkcode Pokemon}'s move history.
*
* @param expectedValue - The expected value; can be a {@linkcode MoveId} or a partially filled {@linkcode TurnMove}
* containing the desired properties to check
* @param index - The index of the move history entry to check, in order from most recent to least recent.
* Default `0` (last used move)
* @see {@linkcode Pokemon.getLastXMoves}
*/
toHaveUsedMove(expected: MoveId | AtLeastOne<TurnMove>, index?: number): void;
/**
* Check whether a {@linkcode Pokemon}'s effective stat is as expected
* (checked after all stat value modifications).
*
* @param stat - The {@linkcode EffectiveStat} to check
* @param expectedValue - The expected value of {@linkcode stat}
* @param options - (Optional) The {@linkcode ToHaveEffectiveStatMatcherOptions}
* @remarks
* If you want to check the stat **before** modifiers are applied, use {@linkcode Pokemon.getStat} instead.
*/
toHaveEffectiveStat(stat: EffectiveStat, expectedValue: number, options?: ToHaveEffectiveStatMatcherOptions): void;
/**
* Check whether a {@linkcode Pokemon} has taken a specific amount of damage.
* @param expectedDamageTaken - The expected amount of damage taken
* @param roundDown - Whether to round down {@linkcode expectedDamageTaken} with {@linkcode toDmgValue}; default `true`
*/
toHaveTakenDamage(expectedDamageTaken: number, roundDown?: boolean): void;
/**
* Check whether the current {@linkcode WeatherType} is as expected.
* @param expectedWeatherType - The expected {@linkcode WeatherType}
*/
toHaveWeather(expectedWeatherType: WeatherType): void;
/**
* Check whether the current {@linkcode TerrainType} is as expected.
* @param expectedTerrainType - The expected {@linkcode TerrainType}
*/
toHaveTerrain(expectedTerrainType: TerrainType): void;
/**
* Check whether a {@linkcode Pokemon} is at full HP.
*/
toHaveFullHp(): void;
/**
* Check whether a {@linkcode Pokemon} has a specific {@linkcode StatusEffect | non-volatile status effect}.
* @param expectedStatusEffect - The {@linkcode StatusEffect} the Pokemon is expected to have,
* or a partially filled {@linkcode Status} containing the desired properties
*/
toHaveStatusEffect(expectedStatusEffect: expectedStatusType): void;
/**
* Check whether a {@linkcode Pokemon} has a specific {@linkcode Stat} stage.
* @param stat - The {@linkcode BattleStat} to check
* @param expectedStage - The expected stat stage value of {@linkcode stat}
*/
toHaveStatStage(stat: BattleStat, expectedStage: number): void;
/**
* Check whether a {@linkcode Pokemon} has a specific {@linkcode BattlerTagType}.
* @param expectedBattlerTagType - The expected {@linkcode BattlerTagType}
*/
toHaveBattlerTag(expectedBattlerTagType: BattlerTagType): void;
/**
* Check whether a {@linkcode Pokemon} has applied a specific {@linkcode AbilityId}.
* @param expectedAbilityId - The expected {@linkcode AbilityId}
*/
toHaveAbilityApplied(expectedAbilityId: AbilityId): void;
/**
* Check whether a {@linkcode Pokemon} has a specific amount of {@linkcode Stat.HP | HP}.
* @param expectedHp - The expected amount of {@linkcode Stat.HP | HP} to have
*/
toHaveHp(expectedHp: number): void;
/**
* Check whether a {@linkcode Pokemon} is currently fainted (as determined by {@linkcode Pokemon.isFainted}).
* @remarks
* When checking whether an enemy wild Pokemon is fainted, one must reference it in a variable _before_ the fainting effect occurs
* as otherwise the Pokemon will be GC'ed and rendered `undefined`.
*/
toHaveFainted(): void;
/**
* Check whether a {@linkcode Pokemon} has consumed the given amount of PP for one of its moves.
* @param expectedValue - The {@linkcode MoveId} of the {@linkcode PokemonMove} that should have consumed PP
* @param ppUsed - The numerical amount of PP that should have been consumed,
* or `all` to indicate the move should be _out_ of PP
* @remarks
* If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.OPP_MOVESET_OVERRIDE},
* does not contain {@linkcode expectedMove}
* or contains the desired move more than once, this will fail the test.
*/
toHaveUsedPP(expectedMove: MoveId, ppUsed: number | "all"): void;
}
}

View File

@ -1,5 +1,18 @@
import { toEqualArrayUnsorted } from "#test/test-utils/matchers/to-equal-array-unsorted";
import { toHaveAbilityApplied } from "#test/test-utils/matchers/to-have-ability-applied";
import { toHaveBattlerTag } from "#test/test-utils/matchers/to-have-battler-tag";
import { toHaveEffectiveStat } from "#test/test-utils/matchers/to-have-effective-stat";
import { toHaveFainted } from "#test/test-utils/matchers/to-have-fainted";
import { toHaveFullHp } from "#test/test-utils/matchers/to-have-full-hp";
import { toHaveHp } from "#test/test-utils/matchers/to-have-hp";
import { toHaveStatStage } from "#test/test-utils/matchers/to-have-stat-stage";
import { toHaveStatusEffect } from "#test/test-utils/matchers/to-have-status-effect";
import { toHaveTakenDamage } from "#test/test-utils/matchers/to-have-taken-damage";
import { toHaveTerrain } from "#test/test-utils/matchers/to-have-terrain";
import { toHaveTypes } from "#test/test-utils/matchers/to-have-types";
import { toHaveUsedMove } from "#test/test-utils/matchers/to-have-used-move";
import { toHaveUsedPP } from "#test/test-utils/matchers/to-have-used-pp";
import { toHaveWeather } from "#test/test-utils/matchers/to-have-weather";
import { expect } from "vitest";
/*
@ -10,4 +23,17 @@ import { expect } from "vitest";
expect.extend({
toEqualArrayUnsorted,
toHaveTypes,
toHaveUsedMove,
toHaveEffectiveStat,
toHaveTakenDamage,
toHaveWeather,
toHaveTerrain,
toHaveFullHp,
toHaveStatusEffect,
toHaveStatStage,
toHaveBattlerTag,
toHaveAbilityApplied,
toHaveHp,
toHaveFainted,
toHaveUsedPP,
});

View File

@ -1,5 +1,4 @@
import { GameManager } from "#test/test-utils/game-manager";
import { waitUntil } from "#test/test-utils/game-manager-utils";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@ -36,19 +35,6 @@ describe("Test misc", () => {
expect(spy).toHaveBeenCalled();
});
// it.skip("test apifetch mock async", async () => {
// const spy = vi.fn();
// await apiFetch("https://localhost:8080/account/info").then(response => {
// expect(response.status).toBe(200);
// expect(response.ok).toBe(true);
// return response.json();
// }).then(data => {
// spy(); // Call the spy function
// expect(data).toEqual({ "username": "greenlamp", "lastSessionSlot": 0 });
// });
// expect(spy).toHaveBeenCalled();
// });
it("test fetch mock sync", async () => {
const response = await fetch("https://localhost:8080/account/info");
const data = await response.json();
@ -62,19 +48,4 @@ describe("Test misc", () => {
const data = await game.scene.cachedFetch("./battle-anims/splishy-splash.json");
expect(data).toBeDefined();
});
it("testing wait phase queue", async () => {
const fakeScene = {
phaseQueue: [1, 2, 3], // Initially not empty
};
setTimeout(() => {
fakeScene.phaseQueue = [];
}, 500);
const spy = vi.fn();
await waitUntil(() => fakeScene.phaseQueue.length === 0).then(result => {
expect(result).toBe(true);
spy(); // Call the spy function
});
expect(spy).toHaveBeenCalled();
});
});

View File

@ -2,6 +2,7 @@ import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id";
import { WeatherType } from "#enums/weather-type";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -23,68 +24,64 @@ describe("Moves - Grudge", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([MoveId.EMBER, MoveId.SPLASH])
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.SHEDINJA)
.enemyAbility(AbilityId.WONDER_GUARD)
.enemyMoveset([MoveId.GRUDGE, MoveId.SPLASH]);
.enemySpecies(SpeciesId.RATTATA)
.startingLevel(100)
.enemyAbility(AbilityId.NO_GUARD);
});
it("should reduce the PP of the Pokemon's move to 0 when the user has fainted", async () => {
it("should reduce the PP of an attack that faints the user to 0", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const playerPokemon = game.field.getPlayerPokemon();
game.move.select(MoveId.EMBER);
await game.move.selectEnemyMove(MoveId.GRUDGE);
const feebas = game.field.getPlayerPokemon();
const ratatta = game.field.getEnemyPokemon();
game.move.use(MoveId.GUILLOTINE);
await game.move.forceEnemyMove(MoveId.GRUDGE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
await game.phaseInterceptor.to("FaintPhase");
const playerMove = playerPokemon.getMoveset().find(m => m.moveId === MoveId.EMBER);
expect(playerMove?.getPpRatio()).toBe(0);
// Ratatta should have fainted and consumed all of Guillotine's PP
expect(ratatta).toHaveFainted();
expect(feebas).toHaveUsedPP(MoveId.GUILLOTINE, "all");
});
it("should remain in effect until the user's next move", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const playerPokemon = game.field.getPlayerPokemon();
game.move.select(MoveId.SPLASH);
await game.move.selectEnemyMove(MoveId.GRUDGE);
const feebas = game.field.getPlayerPokemon();
const ratatta = game.field.getEnemyPokemon();
game.move.use(MoveId.SPLASH);
await game.move.forceEnemyMove(MoveId.GRUDGE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
game.move.select(MoveId.EMBER);
await game.move.selectEnemyMove(MoveId.SPLASH);
game.move.use(MoveId.GUILLOTINE);
await game.move.forceEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.phaseInterceptor.to("BerryPhase");
await game.toEndOfTurn();
const playerMove = playerPokemon.getMoveset().find(m => m.moveId === MoveId.EMBER);
expect(playerMove?.getPpRatio()).toBe(0);
expect(ratatta).toHaveFainted();
expect(feebas).toHaveUsedPP(MoveId.GUILLOTINE, "all");
});
it("should not reduce the opponent's PP if the user dies to weather/indirect damage", async () => {
it("should not reduce PP if the user dies to weather/indirect damage", async () => {
// Opponent will be reduced to 1 HP by False Swipe, then faint to Sandstorm
game.override
.moveset([MoveId.FALSE_SWIPE])
.startingLevel(100)
.ability(AbilityId.SAND_STREAM)
.enemySpecies(SpeciesId.RATTATA);
await game.classicMode.startBattle([SpeciesId.GEODUDE]);
game.override.weather(WeatherType.SANDSTORM);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const enemyPokemon = game.field.getEnemyPokemon();
const playerPokemon = game.field.getPlayerPokemon();
const feebas = game.field.getPlayerPokemon();
const ratatta = game.field.getEnemyPokemon();
game.move.select(MoveId.FALSE_SWIPE);
await game.move.selectEnemyMove(MoveId.GRUDGE);
game.move.use(MoveId.FALSE_SWIPE);
await game.move.forceEnemyMove(MoveId.GRUDGE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase");
await game.toEndOfTurn();
expect(enemyPokemon.isFainted()).toBe(true);
const playerMove = playerPokemon.getMoveset().find(m => m.moveId === MoveId.FALSE_SWIPE);
expect(playerMove?.getPpRatio()).toBeGreaterThan(0);
expect(ratatta).toHaveFainted();
expect(feebas).toHaveUsedPP(MoveId.FALSE_SWIPE, 1);
});
});

126
test/moves/spite.test.ts Normal file
View File

@ -0,0 +1,126 @@
import { AbilityId } from "#enums/ability-id";
import { BattlerIndex } from "#enums/battler-index";
import { MoveId } from "#enums/move-id";
import { MoveResult } from "#enums/move-result";
import { MoveUseMode } from "#enums/move-use-mode";
import { SpeciesId } from "#enums/species-id";
import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
describe("Moves - Spite", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.ability(AbilityId.BALL_FETCH)
.battleStyle("single")
.criticalHits(false)
.enemySpecies(SpeciesId.MAGIKARP)
.enemyAbility(AbilityId.BALL_FETCH)
.startingLevel(100)
.enemyLevel(100);
});
it("should reduce the PP of the target's last used move by 4", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const karp = game.field.getEnemyPokemon();
game.move.changeMoveset(karp, [MoveId.SPLASH, MoveId.TACKLE]);
game.move.use(MoveId.SPITE);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toNextTurn();
expect(karp).toHaveUsedPP(MoveId.TACKLE, 1);
game.move.use(MoveId.SPITE);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
expect(karp).toHaveUsedPP(MoveId.TACKLE, 4 + 1);
});
it("should fail if the target has not used a move", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const karp = game.field.getEnemyPokemon();
game.move.changeMoveset(karp, [MoveId.SPLASH, MoveId.TACKLE]);
game.move.use(MoveId.SPITE);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
const feebas = game.field.getPlayerPokemon();
expect(feebas).toHaveUsedMove({ move: MoveId.SPITE, result: MoveResult.FAIL });
});
it("should fail if the target's last used move is out of PP", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const karp = game.field.getEnemyPokemon();
game.move.changeMoveset(karp, [MoveId.TACKLE]);
karp.moveset[0].ppUsed = 0;
game.move.use(MoveId.SPITE);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
const feebas = game.field.getPlayerPokemon();
expect(feebas).toHaveUsedMove({ move: MoveId.SPITE, result: MoveResult.FAIL });
});
it("should fail if the target's last used move is not in their moveset", async () => {
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const karp = game.field.getEnemyPokemon();
game.move.changeMoveset(karp, [MoveId.TACKLE]);
// Fake magikarp having used Splash the turn prior
karp.pushMoveHistory({ move: MoveId.SPLASH, targets: [BattlerIndex.ENEMY], useMode: MoveUseMode.NORMAL });
game.move.use(MoveId.SPITE);
await game.move.selectEnemyMove(MoveId.TACKLE);
await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]);
await game.toEndOfTurn();
const feebas = game.field.getPlayerPokemon();
expect(feebas).toHaveUsedMove({ move: MoveId.SPITE, result: MoveResult.FAIL });
});
it("should ignore virtual and Dancer-induced moves", async () => {
game.override.battleStyle("double").enemyAbility(AbilityId.DANCER);
game.move.forceMetronomeMove(MoveId.SPLASH);
await game.classicMode.startBattle([SpeciesId.FEEBAS]);
const [karp1, karp2] = game.scene.getEnemyField();
game.move.changeMoveset(karp1, [MoveId.SPLASH, MoveId.METRONOME, MoveId.SWORDS_DANCE]);
game.move.changeMoveset(karp2, [MoveId.SWORDS_DANCE, MoveId.TACKLE]);
game.move.use(MoveId.SPITE);
await game.move.selectEnemyMove(MoveId.METRONOME);
await game.move.selectEnemyMove(MoveId.SWORDS_DANCE);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.ENEMY_2, BattlerIndex.PLAYER]);
await game.toEndOfTurn();
// Spite ignored virtual splash and swords dance, instead only docking from metronome
expect(karp1).toHaveUsedPP(MoveId.SPLASH, 0);
expect(karp1).toHaveUsedPP(MoveId.SWORDS_DANCE, 0);
expect(karp1).toHaveUsedPP(MoveId.METRONOME, 5);
});
});

View File

@ -206,7 +206,7 @@ describe("Moves - Whirlwind", () => {
await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.TOTODILE]);
// expect the enemy to have at least 4 pokemon, necessary for this check to even work
expect(game.scene.getEnemyParty().length, "enemy must have exactly 4 pokemon").toBe(4);
expect(game.scene.getEnemyParty().length, "enemy must have exactly 4 pokemon").toBeGreaterThanOrEqual(4);
const user = game.field.getPlayerPokemon();

View File

@ -174,7 +174,7 @@ describe("Berries Abound - Mystery Encounter", () => {
});
it("should start battle if fastest pokemon is slower than boss below wave 50", async () => {
game.override.startingWave(41);
game.override.startingWave(42);
const encounterTextSpy = vi.spyOn(EncounterDialogueUtils, "showEncounterText");
await game.runToMysteryEncounter(MysteryEncounterType.BERRIES_ABOUND, defaultParty);

View File

@ -253,7 +253,7 @@ describe("Bug-Type Superfan - Mystery Encounter", () => {
});
it("should start battle against the Bug-Type Superfan with wave 70 party template", async () => {
game.override.startingWave(61);
game.override.startingWave(63);
await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty);
await runMysteryEncounterToEnd(game, 1, undefined, true);
@ -268,7 +268,7 @@ describe("Bug-Type Superfan - Mystery Encounter", () => {
});
it("should start battle against the Bug-Type Superfan with wave 100 party template", async () => {
game.override.startingWave(81);
game.override.startingWave(83);
await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty);
await runMysteryEncounterToEnd(game, 1, undefined, true);
@ -284,7 +284,7 @@ describe("Bug-Type Superfan - Mystery Encounter", () => {
});
it("should start battle against the Bug-Type Superfan with wave 120 party template", async () => {
game.override.startingWave(111);
game.override.startingWave(113);
await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty);
await runMysteryEncounterToEnd(game, 1, undefined, true);
@ -302,7 +302,7 @@ describe("Bug-Type Superfan - Mystery Encounter", () => {
});
it("should start battle against the Bug-Type Superfan with wave 140 party template", async () => {
game.override.startingWave(131);
game.override.startingWave(133);
await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty);
await runMysteryEncounterToEnd(game, 1, undefined, true);
@ -320,7 +320,7 @@ describe("Bug-Type Superfan - Mystery Encounter", () => {
});
it("should start battle against the Bug-Type Superfan with wave 160 party template", async () => {
game.override.startingWave(151);
game.override.startingWave(153);
await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty);
await runMysteryEncounterToEnd(game, 1, undefined, true);
@ -338,7 +338,7 @@ describe("Bug-Type Superfan - Mystery Encounter", () => {
});
it("should start battle against the Bug-Type Superfan with wave 180 party template", async () => {
game.override.startingWave(171);
game.override.startingWave(173);
await game.runToMysteryEncounter(MysteryEncounterType.BUG_TYPE_SUPERFAN, defaultParty);
await runMysteryEncounterToEnd(game, 1, undefined, true);

View File

@ -79,14 +79,6 @@ describe("Teleporting Hijinks - Mystery Encounter", () => {
expect(TeleportingHijinksEncounter.options.length).toBe(3);
});
it("should run in waves that are X1", async () => {
game.override.startingWave(11).mysteryEncounterTier(MysteryEncounterTier.COMMON);
await game.runToMysteryEncounter();
expect(scene.currentBattle?.mysteryEncounter?.encounterType).toBe(MysteryEncounterType.TELEPORTING_HIJINKS);
});
it("should run in waves that are X2", async () => {
game.override.startingWave(32).mysteryEncounterTier(MysteryEncounterTier.COMMON);
@ -103,8 +95,16 @@ describe("Teleporting Hijinks - Mystery Encounter", () => {
expect(scene.currentBattle?.mysteryEncounter?.encounterType).toBe(MysteryEncounterType.TELEPORTING_HIJINKS);
});
it("should NOT run in waves that are not X1, X2, or X3", async () => {
game.override.startingWave(54);
it("should run in waves that are X4", async () => {
game.override.startingWave(54).mysteryEncounterTier(MysteryEncounterTier.COMMON);
await game.runToMysteryEncounter();
expect(scene.currentBattle?.mysteryEncounter?.encounterType).toBe(MysteryEncounterType.TELEPORTING_HIJINKS);
});
it("should NOT run in waves that are not X2, X3, or X4", async () => {
game.override.startingWave(67);
await game.runToMysteryEncounter();

View File

@ -24,7 +24,7 @@ describe("Mystery Encounters", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
scene = game.scene;
game.override.startingWave(11).mysteryEncounterChance(100);
game.override.startingWave(12).mysteryEncounterChance(100);
});
it("Spawns a mystery encounter", async () => {
@ -37,12 +37,20 @@ describe("Mystery Encounters", () => {
expect(game.scene.phaseManager.getCurrentPhase()!.constructor.name).toBe(MysteryEncounterPhase.name);
});
it("Encounters should not run on X1 waves", async () => {
game.override.startingWave(11);
await game.runToMysteryEncounter();
expect(scene.currentBattle.mysteryEncounter).toBeUndefined();
});
it("Encounters should not run below wave 10", async () => {
game.override.startingWave(9);
await game.runToMysteryEncounter();
expect(scene.currentBattle?.mysteryEncounter?.encounterType).not.toBe(MysteryEncounterType.MYSTERIOUS_CHALLENGERS);
expect(scene.currentBattle.mysteryEncounter).toBeUndefined();
});
it("Encounters should not run above wave 180", async () => {

View File

@ -27,7 +27,7 @@ describe("Mystery Encounter Phases", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override.startingWave(11).mysteryEncounterChance(100).seed("test"); // Seed guarantees wild encounter to be replaced by ME
game.override.startingWave(12).mysteryEncounterChance(100).seed("test"); // Seed guarantees wild encounter to be replaced by ME
});
describe("MysteryEncounterPhase", () => {

View File

@ -3,7 +3,6 @@ import type { BattleScene } from "#app/battle-scene";
import { getGameMode } from "#app/game-mode";
import { getDailyRunStarters } from "#data/daily-run";
import { Gender } from "#data/gender";
import { getPokemonSpeciesForm } from "#data/pokemon-species";
import { BattleType } from "#enums/battle-type";
import { GameModes } from "#enums/game-modes";
import type { MoveId } from "#enums/move-id";
@ -11,7 +10,7 @@ import type { SpeciesId } from "#enums/species-id";
import { PlayerPokemon } from "#field/pokemon";
import type { StarterMoveset } from "#system/game-data";
import type { Starter } from "#ui/starter-select-ui-handler";
import { getPokemonSpecies } from "#utils/pokemon-utils";
import { getPokemonSpecies, getPokemonSpeciesForm } from "#utils/pokemon-utils";
/** Function to convert Blob to string */
export function blobToString(blob) {
@ -87,17 +86,6 @@ function getTestRunStarters(seed: string, species?: SpeciesId[]): Starter[] {
return starters;
}
export function waitUntil(truth): Promise<unknown> {
return new Promise(resolve => {
const interval = setInterval(() => {
if (truth()) {
clearInterval(interval);
resolve(true);
}
}, 1000);
});
}
/**
* Useful for populating party, wave index, etc. without having to spin up and run through an entire EncounterPhase
*/

View File

@ -31,7 +31,7 @@ import { TurnEndPhase } from "#phases/turn-end-phase";
import { TurnInitPhase } from "#phases/turn-init-phase";
import { TurnStartPhase } from "#phases/turn-start-phase";
import { ErrorInterceptor } from "#test/test-utils/error-interceptor";
import { generateStarter, waitUntil } from "#test/test-utils/game-manager-utils";
import { generateStarter } from "#test/test-utils/game-manager-utils";
import { GameWrapper } from "#test/test-utils/game-wrapper";
import { ChallengeModeHelper } from "#test/test-utils/helpers/challenge-mode-helper";
import { ClassicModeHelper } from "#test/test-utils/helpers/classic-mode-helper";
@ -85,30 +85,22 @@ export class GameManager {
constructor(phaserGame: Phaser.Game, bypassLogin = true) {
localStorage.clear();
ErrorInterceptor.getInstance().clear();
BattleScene.prototype.randBattleSeedInt = (range, min = 0) => min + range - 1; // This simulates a max roll
// Simulate max rolls on RNG functions
// TODO: Create helpers for disabling/enabling battle RNG
BattleScene.prototype.randBattleSeedInt = (range, min = 0) => min + range - 1;
this.gameWrapper = new GameWrapper(phaserGame, bypassLogin);
let firstTimeScene = false;
// TODO: Figure out a way to optimize and re-use the same game manager for each test
// Re-use an existing `globalScene` if present, or else create a new scene from scratch.
if (globalScene) {
this.scene = globalScene;
this.phaseInterceptor = new PhaseInterceptor(this.scene);
this.resetScene();
} else {
this.scene = new BattleScene();
this.phaseInterceptor = new PhaseInterceptor(this.scene);
this.gameWrapper.setScene(this.scene);
firstTimeScene = true;
}
this.phaseInterceptor = new PhaseInterceptor(this.scene);
if (!firstTimeScene) {
this.scene.reset(false, true);
(this.scene.ui.handlers[UiMode.STARTER_SELECT] as StarterSelectUiHandler).clearStarterPreferences();
// Must be run after phase interceptor has been initialized.
this.scene.phaseManager.toTitleScreen(true);
this.scene.phaseManager.shiftPhase();
this.gameWrapper.scene = this.scene;
}
this.textInterceptor = new TextInterceptor(this.scene);
@ -122,10 +114,30 @@ export class GameManager {
this.modifiers = new ModifierHelper(this);
this.field = new FieldHelper(this);
this.initDefaultOverrides();
// TODO: remove `any` assertion
global.fetch = vi.fn(MockFetch) as any;
}
/** Reset a prior `BattleScene` instance to the proper initial state. */
private resetScene(): void {
this.scene.reset(false, true);
(this.scene.ui.handlers[UiMode.STARTER_SELECT] as StarterSelectUiHandler).clearStarterPreferences();
this.gameWrapper.scene = this.scene;
this.scene.phaseManager.toTitleScreen(true);
this.scene.phaseManager.shiftPhase();
}
/**
* Initialize various default overrides for starting tests, typically to alleviate randomness.
*/
// TODO: This should not be here
private initDefaultOverrides(): void {
// Disables Mystery Encounters on all tests (can be overridden at test level)
this.override.mysteryEncounterChance(0);
global.fetch = vi.fn(MockFetch) as any;
}
/**
@ -141,15 +153,13 @@ export class GameManager {
* @param mode - The mode to wait for.
* @returns A promise that resolves when the mode is set.
*/
waitMode(mode: UiMode): Promise<void> {
return new Promise(async resolve => {
await waitUntil(() => this.scene.ui?.getMode() === mode);
return resolve();
});
// TODO: This is unused
async waitMode(mode: UiMode): Promise<void> {
await vi.waitUntil(() => this.scene.ui?.getMode() === mode);
}
/**
* Ends the current phase.
* End the currently running phase immediately.
*/
endPhase() {
this.scene.phaseManager.getCurrentPhase()?.end();
@ -283,11 +293,14 @@ export class GameManager {
.getPokemon()
.getMoveset()
[movePosition].getMove();
if (!move.isMultiTarget()) {
handler.setCursor(targetIndex !== undefined ? targetIndex : BattlerIndex.ENEMY);
}
if (move.isMultiTarget() && targetIndex !== undefined) {
expect.fail(`targetIndex was passed to selectMove() but move ("${move.name}") is not targetted`);
// Multi target attacks do not select a target
if (move.isMultiTarget()) {
if (targetIndex !== undefined) {
expect.fail(`targetIndex was passed to selectMove() but move ("${move.name}") is not targeted`);
}
} else {
handler.setCursor(targetIndex ?? BattlerIndex.ENEMY);
}
handler.processInput(Button.ACTION);
},

View File

@ -143,7 +143,7 @@ export class MoveHelper extends GameManagerHelper {
}
}
/** Helper function to get the index of the selected move in the selected part member's moveset. */
/** Helper function to get the index of the selected move in the selected party member's moveset. */
private getMovePosition(pokemonIndex: BattlerIndex.PLAYER | BattlerIndex.PLAYER_2, move: MoveId): number {
const playerPokemon = this.game.scene.getPlayerField()[pokemonIndex];
const moveset = playerPokemon.getMoveset();
@ -153,17 +153,18 @@ export class MoveHelper extends GameManagerHelper {
}
/**
* Modifies a player pokemon's moveset to contain only the selected move and then
* Modifies a player pokemon's moveset to contain only the selected move, and then
* selects it to be used during the next {@linkcode CommandPhase}.
*
* Warning: Will disable the player moveset override if it is enabled!
* **Warning**: Will disable the player moveset override if it is enabled, as well as any mid-battle moveset changes!
*
* Note: If you need to check for changes in the player's moveset as part of the test, it may be
* best to use {@linkcode changeMoveset} and {@linkcode select} instead.
* @param moveId - the move to use
* @param pkmIndex - The {@linkcode BattlerIndex} of the player Pokemon using the move. Relevant for double battles only and defaults to {@linkcode BattlerIndex.PLAYER} if not specified.
* @param targetIndex - The {@linkcode BattlerIndex} of the Pokemon to target for single-target moves; should be omitted for multi-target moves.
* @param useTera - If `true`, the Pokemon will attempt to Terastallize even without a Tera Orb; default `false`.
* @param moveId - The {@linkcode MoveId} to use
* @param pkmIndex - The {@linkcode BattlerIndex} of the player Pokemon using the move. Relevant for double battles only and defaults to {@linkcode BattlerIndex.PLAYER} if not specified
* @param targetIndex - The {@linkcode BattlerIndex} of the Pokemon to target for single-target moves; should be omitted for multi-target moves
* @param useTera - If `true`, the Pokemon will attempt to Terastallize even without a Tera Orb; default `false`
* @remarks
* If you need to check for changes in the player's moveset as part of the test, it may be
* better to use {@linkcode changeMoveset} and {@linkcode select} instead.
*/
public use(
moveId: MoveId,
@ -176,8 +177,11 @@ export class MoveHelper extends GameManagerHelper {
console.warn("Warning: `MoveHelper.use` overwriting player pokemon moveset and disabling moveset override!");
}
// Clear out both the normal and temporary movesets before setting the move.
const pokemon = this.game.scene.getPlayerField()[pkmIndex];
pokemon.moveset = [new PokemonMove(moveId)];
pokemon.moveset.splice(0);
pokemon.summonData.moveset?.splice(0);
pokemon.setMove(0, moveId);
if (useTera) {
this.selectWithTera(moveId, pkmIndex, targetIndex);
@ -211,7 +215,7 @@ export class MoveHelper extends GameManagerHelper {
/**
* Changes a pokemon's moveset to the given move(s).
*
* Used when the normal moveset override can't be used (such as when it's necessary to check or update properties of the moveset).
* Useful when normal moveset overrides can't be used (such as when it's necessary to check or update properties of the moveset).
*
* **Note**: Will disable the moveset override matching the pokemon's party.
* @param pokemon - The {@linkcode Pokemon} being modified
@ -232,8 +236,8 @@ export class MoveHelper extends GameManagerHelper {
moveset = coerceArray(moveset);
expect(moveset.length, "Cannot assign more than 4 moves to a moveset!").toBeLessThanOrEqual(4);
pokemon.moveset = [];
moveset.forEach(move => {
pokemon.moveset.push(new PokemonMove(move));
moveset.forEach((move, i) => {
pokemon.setMove(i, move);
});
const movesetStr = moveset.map(moveId => MoveId[moveId]).join(", ");
console.log(`Pokemon ${pokemon.species.name}'s moveset manually set to ${movesetStr} (=[${moveset.join(", ")}])!`);

View File

@ -1,43 +1,47 @@
import { getOnelineDiffStr } from "#test/test-utils/string-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check if an array contains exactly the given items, disregarding order.
* @param received - The object to check. Should be an array of elements.
* @returns The result of the matching
* Matcher that checks if an array contains exactly the given items, disregarding order.
* @param received - The received value. Should be an array of elements
* @param expected - The array to check equality with
* @returns Whether the matcher passed
*/
export function toEqualArrayUnsorted(this: MatcherState, received: unknown, expected: unknown): SyncExpectationResult {
export function toEqualArrayUnsorted(
this: MatcherState,
received: unknown,
expected: unknown[],
): SyncExpectationResult {
if (!Array.isArray(received)) {
return {
pass: this.isNot,
pass: false,
message: () => `Expected an array, but got ${this.utils.stringify(received)}!`,
};
}
if (!Array.isArray(expected)) {
return {
pass: this.isNot,
message: () => `Expected to recieve an array, but got ${this.utils.stringify(expected)}!`,
};
}
if (received.length !== expected.length) {
return {
pass: this.isNot,
message: () => `Expected to recieve array of length ${received.length}, but got ${expected.length}!`,
pass: false,
message: () => `Expected to receive array of length ${received.length}, but got ${expected.length} instead!`,
actual: received,
expected,
};
}
const gotSorted = received.slice().sort();
const wantSorted = expected.slice().sort();
const pass = this.equals(gotSorted, wantSorted, [...this.customTesters, this.utils.iterableEquality]);
const actualSorted = received.slice().sort();
const expectedSorted = expected.slice().sort();
const pass = this.equals(actualSorted, expectedSorted, [...this.customTesters, this.utils.iterableEquality]);
const actualStr = getOnelineDiffStr.call(this, actualSorted);
const expectedStr = getOnelineDiffStr.call(this, expectedSorted);
return {
pass: this.isNot !== pass,
pass,
message: () =>
`Expected ${this.utils.stringify(received)} to exactly equal ${this.utils.stringify(expected)} without order!`,
actual: gotSorted,
expected: wantSorted,
pass
? `Expected ${actualStr} to NOT exactly equal ${expectedStr} without order, but it did!`
: `Expected ${actualStr} to exactly equal ${expectedStr} without order, but it didn't!`,
expected: expectedSorted,
actual: actualSorted,
};
}

View File

@ -0,0 +1,43 @@
/* biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */
import type { Pokemon } from "#field/pokemon";
/* biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */
import { getPokemonNameWithAffix } from "#app/messages";
import { AbilityId } from "#enums/ability-id";
import { getEnumStr } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if a {@linkcode Pokemon} has applied a specific {@linkcode AbilityId}.
* @param received - The object to check. Should be a {@linkcode Pokemon}
* @param expectedAbility - The {@linkcode AbilityId} to check for
* @returns Whether the matcher passed
*/
export function toHaveAbilityApplied(
this: MatcherState,
received: unknown,
expectedAbilityId: AbilityId,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to recieve a Pokemon, but got ${receivedStr(received)}!`,
};
}
const pass = received.waveData.abilitiesApplied.has(expectedAbilityId);
const pkmName = getPokemonNameWithAffix(received);
const expectedAbilityStr = getEnumStr(AbilityId, expectedAbilityId);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have applied ${expectedAbilityStr}, but it did!`
: `Expected ${pkmName} to have applied ${expectedAbilityStr}, but it didn't!`,
expected: expectedAbilityId,
actual: received.waveData.abilitiesApplied,
};
}

View File

@ -0,0 +1,43 @@
/* biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */
import type { Pokemon } from "#field/pokemon";
/* biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */
import { getPokemonNameWithAffix } from "#app/messages";
import { BattlerTagType } from "#enums/battler-tag-type";
import { getEnumStr } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if a {@linkcode Pokemon} has a specific {@linkcode BattlerTagType}.
* @param received - The object to check. Should be a {@linkcode Pokemon}
* @param expectedBattlerTagType - The {@linkcode BattlerTagType} to check for
* @returns Whether the matcher passed
*/
export function toHaveBattlerTag(
this: MatcherState,
received: unknown,
expectedBattlerTagType: BattlerTagType,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: this.isNot,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const pass = !!received.getTag(expectedBattlerTagType);
const pkmName = getPokemonNameWithAffix(received);
// "BattlerTagType.SEEDED (=1)"
const expectedTagStr = getEnumStr(BattlerTagType, expectedBattlerTagType, { prefix: "BattlerTagType." });
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have ${expectedTagStr}, but it did!`
: `Expected ${pkmName} to have ${expectedTagStr}, but it didn't!`,
expected: expectedBattlerTagType,
actual: received.summonData.tags.map(t => t.tagType),
};
}

View File

@ -0,0 +1,66 @@
import { getPokemonNameWithAffix } from "#app/messages";
import type { EffectiveStat } from "#enums/stat";
import type { Pokemon } from "#field/pokemon";
import type { Move } from "#moves/move";
import { getStatName } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export interface ToHaveEffectiveStatMatcherOptions {
/**
* The target {@linkcode Pokemon}
* @see {@linkcode Pokemon.getEffectiveStat}
*/
enemy?: Pokemon;
/**
* The {@linkcode Move} being used
* @see {@linkcode Pokemon.getEffectiveStat}
*/
move?: Move;
/**
* Whether a critical hit occurred or not
* @see {@linkcode Pokemon.getEffectiveStat}
* @defaultValue `false`
*/
isCritical?: boolean;
}
/**
* Matcher that checks if a {@linkcode Pokemon}'s effective stat equals a certain value.
* @param received - The object to check. Should be a {@linkcode Pokemon}
* @param stat - The {@linkcode EffectiveStat} to check
* @param expectedValue - The expected value of the {@linkcode stat}
* @param options - The {@linkcode ToHaveEffectiveStatMatcherOptions}
* @returns Whether the matcher passed
*/
export function toHaveEffectiveStat(
this: MatcherState,
received: unknown,
stat: EffectiveStat,
expectedValue: number,
{ enemy, move, isCritical = false }: ToHaveEffectiveStatMatcherOptions = {},
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
// TODO: Change once getEffectiveStat is refactored to take an object literal
const actualValue = received.getEffectiveStat(stat, enemy, move, undefined, undefined, undefined, isCritical);
const pass = actualValue === expectedValue;
const pkmName = getPokemonNameWithAffix(received);
const statName = getStatName(stat);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have ${expectedValue} ${statName}, but it did!`
: `Expected ${pkmName} to have ${expectedValue} ${statName}, but got ${actualValue} instead!`,
expected: expectedValue,
actual: actualValue,
};
}

View File

@ -0,0 +1,35 @@
import { getPokemonNameWithAffix } from "#app/messages";
// biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { Pokemon } from "#field/pokemon";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if a {@linkcode Pokemon} has fainted.
* @param received - The object to check. Should be a {@linkcode Pokemon}
* @returns Whether the matcher passed
*/
export function toHaveFainted(this: MatcherState, received: unknown): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const pass = received.isFainted();
const hp = received.hp;
const maxHp = received.getMaxHp();
const pkmName = getPokemonNameWithAffix(received);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have fainted, but it did!`
: `Expected ${pkmName} to have fainted, but it didn't! (${hp}/${maxHp} HP)`,
expected: 0,
actual: hp,
};
}

View File

@ -0,0 +1,35 @@
import { getPokemonNameWithAffix } from "#app/messages";
// biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { Pokemon } from "#field/pokemon";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if a {@linkcode Pokemon} is at full hp.
* @param received - The object to check. Should be a {@linkcode Pokemon}.
* @returns Whether the matcher passed
*/
export function toHaveFullHp(this: MatcherState, received: unknown): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const pass = received.isFullHp();
const hp = received.hp;
const maxHp = received.getMaxHp();
const pkmName = getPokemonNameWithAffix(received);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have full hp, but it did!`
: `Expected ${pkmName} to have full hp, but it didn't! (${hp}/${maxHp} HP)`,
expected: maxHp,
actual: hp,
};
}

View File

@ -0,0 +1,35 @@
import { getPokemonNameWithAffix } from "#app/messages";
// biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { Pokemon } from "#field/pokemon";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if a Pokemon has a specific amount of HP.
* @param received - The object to check. Should be a {@linkcode Pokemon}.
* @param expectedHp - The expected amount of HP the {@linkcode Pokemon} has
* @returns Whether the matcher passed
*/
export function toHaveHp(this: MatcherState, received: unknown, expectedHp: number): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const actualHp = received.hp;
const pass = actualHp === expectedHp;
const pkmName = getPokemonNameWithAffix(received);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have ${expectedHp} HP, but it did!`
: `Expected ${pkmName} to have ${expectedHp} HP, but got ${actualHp} HP instead!`,
expected: expectedHp,
actual: actualHp,
};
}

View File

@ -0,0 +1,53 @@
/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */
import type { Pokemon } from "#field/pokemon";
/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */
import { getPokemonNameWithAffix } from "#app/messages";
import type { BattleStat } from "#enums/stat";
import { getStatName } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if a Pokemon has a specific {@linkcode BattleStat | Stat} stage.
* @param received - The object to check. Should be a {@linkcode Pokemon}.
* @param stat - The {@linkcode BattleStat | Stat} to check
* @param expectedStage - The expected numerical value of {@linkcode stat}; should be within the range `[-6, 6]`
* @returns Whether the matcher passed
*/
export function toHaveStatStage(
this: MatcherState,
received: unknown,
stat: BattleStat,
expectedStage: number,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
if (expectedStage < -6 || expectedStage > 6) {
return {
pass: false,
message: () => `Expected ${expectedStage} to be within the range [-6, 6]!`,
};
}
const actualStage = received.getStatStage(stat);
const pass = actualStage === expectedStage;
const pkmName = getPokemonNameWithAffix(received);
const statName = getStatName(stat);
return {
pass,
message: () =>
pass
? `Expected ${pkmName}'s ${statName} stat stage to NOT be ${expectedStage}, but it was!`
: `Expected ${pkmName}'s ${statName} stat stage to be ${expectedStage}, but got ${actualStage} instead!`,
expected: expectedStage,
actual: actualStage,
};
}

View File

@ -0,0 +1,83 @@
/* biome-ignore-start lint/correctness/noUnusedImports: tsdoc imports */
import type { Status } from "#data/status-effect";
import type { Pokemon } from "#field/pokemon";
/* biome-ignore-end lint/correctness/noUnusedImports: tsdoc imports */
import { getPokemonNameWithAffix } from "#app/messages";
import { StatusEffect } from "#enums/status-effect";
import { getEnumStr, getOnelineDiffStr } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export type expectedStatusType =
| StatusEffect
| { effect: StatusEffect.TOXIC; toxicTurnCount: number }
| { effect: StatusEffect.SLEEP; sleepTurnsRemaining: number };
/**
* Matcher that checks if a Pokemon's {@linkcode StatusEffect} is as expected
* @param received - The actual value received. Should be a {@linkcode Pokemon}
* @param expectedStatus - The {@linkcode StatusEffect} the Pokemon is expected to have,
* or a partially filled {@linkcode Status} containing the desired properties
* @returns Whether the matcher passed
*/
export function toHaveStatusEffect(
this: MatcherState,
received: unknown,
expectedStatus: expectedStatusType,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const pkmName = getPokemonNameWithAffix(received);
const actualEffect = received.status?.effect ?? StatusEffect.NONE;
// Check exclusively effect equality first, coercing non-matching status effects to numbers.
if (actualEffect !== (expectedStatus as Exclude<typeof expectedStatus, StatusEffect>)?.effect) {
// This is actually 100% safe as `expectedStatus?.effect` will evaluate to `undefined` if a StatusEffect was passed,
// which will never match actualEffect by definition
expectedStatus = (expectedStatus as Exclude<typeof expectedStatus, StatusEffect>).effect;
}
if (typeof expectedStatus === "number") {
const pass = this.equals(actualEffect, expectedStatus, [...this.customTesters, this.utils.iterableEquality]);
const actualStr = getEnumStr(StatusEffect, actualEffect, { prefix: "StatusEffect." });
const expectedStr = getEnumStr(StatusEffect, expectedStatus, { prefix: "StatusEffect." });
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have ${expectedStr}, but it did!`
: `Expected ${pkmName} to have status effect ${expectedStr}, but got ${actualStr} instead!`,
expected: expectedStatus,
actual: actualEffect,
};
}
// Check for equality of all fields (for toxic turn count/etc)
const actualStatus = received.status;
const pass = this.equals(received, expectedStatus, [
...this.customTesters,
this.utils.subsetEquality,
this.utils.iterableEquality,
]);
const expectedStr = getOnelineDiffStr.call(this, expectedStatus);
const actualStr = getOnelineDiffStr.call(this, actualStatus);
return {
pass,
message: () =>
pass
? `Expected ${pkmName}'s status to NOT match ${expectedStr}, but it did!`
: `Expected ${pkmName}'s status to match ${expectedStr}, but got ${actualStr} instead!`,
expected: expectedStatus,
actual: actualStatus,
};
}

View File

@ -0,0 +1,46 @@
/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */
import type { Pokemon } from "#field/pokemon";
/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */
import { getPokemonNameWithAffix } from "#app/messages";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import { toDmgValue } from "#utils/common";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if a Pokemon has taken a specific amount of damage.
* Unless specified, will run the expected damage value through {@linkcode toDmgValue}
* to round it down and make it a minimum of 1.
* @param received - The object to check. Should be a {@linkcode Pokemon}.
* @param expectedDamageTaken - The expected amount of damage the {@linkcode Pokemon} has taken
* @param roundDown - Whether to round down {@linkcode expectedDamageTaken} with {@linkcode toDmgValue}; default `true`
* @returns Whether the matcher passed
*/
export function toHaveTakenDamage(
this: MatcherState,
received: unknown,
expectedDamageTaken: number,
roundDown = true,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const expectedDmgValue = roundDown ? toDmgValue(expectedDamageTaken) : expectedDamageTaken;
const actualDmgValue = received.getInverseHp();
const pass = actualDmgValue === expectedDmgValue;
const pkmName = getPokemonNameWithAffix(received);
return {
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have taken ${expectedDmgValue} damage, but it did!`
: `Expected ${pkmName} to have taken ${expectedDmgValue} damage, but got ${actualDmgValue} instead!`,
expected: expectedDmgValue,
actual: actualDmgValue,
};
}

View File

@ -0,0 +1,62 @@
/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */
import type { GameManager } from "#test/test-utils/game-manager";
/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */
import { TerrainType } from "#app/data/terrain";
import { getEnumStr } from "#test/test-utils/string-utils";
import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if the {@linkcode TerrainType} is as expected
* @param received - The object to check. Should be an instance of {@linkcode GameManager}.
* @param expectedTerrainType - The expected {@linkcode TerrainType}, or {@linkcode TerrainType.NONE} if no terrain should be active
* @returns Whether the matcher passed
*/
export function toHaveTerrain(
this: MatcherState,
received: unknown,
expectedTerrainType: TerrainType,
): SyncExpectationResult {
if (!isGameManagerInstance(received)) {
return {
pass: false,
message: () => `Expected GameManager, but got ${receivedStr(received)}!`,
};
}
if (!received.scene?.arena) {
return {
pass: false,
message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`,
};
}
const actual = received.scene.arena.getTerrainType();
const pass = actual === expectedTerrainType;
const actualStr = toTerrainStr(actual);
const expectedStr = toTerrainStr(expectedTerrainType);
return {
pass,
message: () =>
pass
? `Expected Arena to NOT have ${expectedStr} active, but it did!`
: `Expected Arena to have ${expectedStr} active, but got ${actualStr} instead!`,
expected: expectedTerrainType,
actual,
};
}
/**
* Get a human readable string of the current {@linkcode TerrainType}.
* @param terrainType - The {@linkcode TerrainType} to transform
* @returns A human readable string
*/
function toTerrainStr(terrainType: TerrainType) {
if (terrainType === TerrainType.NONE) {
return "no terrain";
}
// "Electric Terrain (=2)"
return getEnumStr(TerrainType, terrainType, { casing: "Title", suffix: " Terrain" });
}

View File

@ -1,6 +1,9 @@
import { getPokemonNameWithAffix } from "#app/messages";
import { PokemonType } from "#enums/pokemon-type";
import { Pokemon } from "#field/pokemon";
import type { Pokemon } from "#field/pokemon";
import { stringifyEnumArray } from "#test/test-utils/string-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
import { isPokemonInstance, receivedStr } from "../test-utils";
export interface toHaveTypesOptions {
/**
@ -15,7 +18,7 @@ export interface toHaveTypesOptions {
}
/**
* Matcher to check if an array contains exactly the given items, disregarding order.
* Matcher that checks if an array contains exactly the given items, disregarding order.
* @param received - The object to check. Should be an array of one or more {@linkcode PokemonType}s.
* @param options - The {@linkcode toHaveTypesOptions | options} for this matcher
* @returns The result of the matching
@ -23,42 +26,36 @@ export interface toHaveTypesOptions {
export function toHaveTypes(
this: MatcherState,
received: unknown,
expected: unknown,
expected: [PokemonType, ...PokemonType[]],
options: toHaveTypesOptions = {},
): SyncExpectationResult {
if (!(received instanceof Pokemon)) {
if (!isPokemonInstance(received)) {
return {
pass: this.isNot,
message: () => `Expected a Pokemon, but got ${this.utils.stringify(received)}!`,
pass: false,
message: () => `Expected to recieve a Pokémon, but got ${receivedStr(received)}!`,
};
}
if (!Array.isArray(expected) || expected.length === 0) {
return {
pass: this.isNot,
message: () => `Expected to recieve an array with length >=1, but got ${this.utils.stringify(expected)}!`,
};
}
const actualTypes = received.getTypes(...(options.args ?? [])).sort();
const expectedTypes = expected.slice().sort();
if (!expected.every((t): t is PokemonType => t in PokemonType)) {
return {
pass: this.isNot,
message: () => `Expected to recieve array of PokemonTypes but got ${this.utils.stringify(expected)}!`,
};
}
// Exact matches do not care about subset equality
const matchers = options.exact
? [...this.customTesters, this.utils.iterableEquality]
: [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality];
const pass = this.equals(actualTypes, expectedTypes, matchers);
const gotSorted = pkmnTypeToStr(received.getTypes(...(options.args ?? [])));
const wantSorted = pkmnTypeToStr(expected.slice());
const pass = this.equals(gotSorted, wantSorted, [...this.customTesters, this.utils.iterableEquality]);
const actualStr = stringifyEnumArray(PokemonType, actualTypes);
const expectedStr = stringifyEnumArray(PokemonType, expectedTypes);
const pkmName = getPokemonNameWithAffix(received);
return {
pass: this.isNot !== pass,
message: () => `Expected ${received.name} to have types ${this.utils.stringify(wantSorted)}, but got ${gotSorted}!`,
actual: gotSorted,
expected: wantSorted,
pass,
message: () =>
pass
? `Expected ${pkmName} to NOT have types ${expectedStr}, but it did!`
: `Expected ${pkmName} to have types ${expectedStr}, but got ${actualStr} instead!`,
expected: expectedTypes,
actual: actualTypes,
};
}
function pkmnTypeToStr(p: PokemonType[]): string[] {
return p.sort().map(type => PokemonType[type]);
}

View File

@ -0,0 +1,70 @@
/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */
import type { Pokemon } from "#field/pokemon";
/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */
import { getPokemonNameWithAffix } from "#app/messages";
import type { MoveId } from "#enums/move-id";
import { getOnelineDiffStr, getOrdinal } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { TurnMove } from "#types/turn-move";
import type { AtLeastOne } from "#types/type-helpers";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check the contents of a {@linkcode Pokemon}'s move history.
* @param received - The actual value received. Should be a {@linkcode Pokemon}
* @param expectedValue - The {@linkcode MoveId} the Pokemon is expected to have used,
* or a partially filled {@linkcode TurnMove} containing the desired properties to check
* @param index - The index of the move history entry to check, in order from most recent to least recent.
* Default `0` (last used move)
* @returns Whether the matcher passed
*/
export function toHaveUsedMove(
this: MatcherState,
received: unknown,
expectedResult: MoveId | AtLeastOne<TurnMove>,
index = 0,
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const move: TurnMove | undefined = received.getLastXMoves(-1)[index];
const pkmName = getPokemonNameWithAffix(received);
if (move === undefined) {
return {
pass: false,
message: () => `Expected ${pkmName} to have used ${index + 1} moves, but it didn't!`,
actual: received.getLastXMoves(-1),
};
}
// Coerce to a `TurnMove`
if (typeof expectedResult === "number") {
expectedResult = { move: expectedResult };
}
const moveIndexStr = index === 0 ? "last move" : `${getOrdinal(index)} most recent move`;
const pass = this.equals(move, expectedResult, [
...this.customTesters,
this.utils.subsetEquality,
this.utils.iterableEquality,
]);
const expectedStr = getOnelineDiffStr.call(this, expectedResult);
return {
pass,
message: () =>
pass
? `Expected ${pkmName}'s ${moveIndexStr} to NOT match ${expectedStr}, but it did!`
: // Replace newlines with spaces to preserve one-line ness
`Expected ${pkmName}'s ${moveIndexStr} to match ${expectedStr}, but it didn't!`,
expected: expectedResult,
actual: move,
};
}

View File

@ -0,0 +1,77 @@
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports
import type { Pokemon } from "#field/pokemon";
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports
import { getPokemonNameWithAffix } from "#app/messages";
import Overrides from "#app/overrides";
import { MoveId } from "#enums/move-id";
import { getEnumStr } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import { coerceArray } from "#utils/common";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher to check the amount of PP consumed by a {@linkcode Pokemon}.
* @param received - The actual value received. Should be a {@linkcode Pokemon}
* @param expectedValue - The {@linkcode MoveId} that should have consumed PP
* @param ppUsed - The numerical amount of PP that should have been consumed,
* or `all` to indicate the move should be _out_ of PP
* @returns Whether the matcher passed
* @remarks
* If the same move appears in the Pokemon's moveset multiple times, this will fail the test!
*/
export function toHaveUsedPP(
this: MatcherState,
received: unknown,
expectedMove: MoveId,
ppUsed: number | "all",
): SyncExpectationResult {
if (!isPokemonInstance(received)) {
return {
pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
};
}
const override = received.isPlayer() ? Overrides.MOVESET_OVERRIDE : Overrides.OPP_MOVESET_OVERRIDE;
if (coerceArray(override).length > 0) {
return {
pass: false,
message: () =>
`Cannot test for PP consumption with ${received.isPlayer() ? "player" : "enemy"} moveset overrides active!`,
};
}
const pkmName = getPokemonNameWithAffix(received);
const moveStr = getEnumStr(MoveId, expectedMove);
const movesetMoves = received.getMoveset().filter(pm => pm.moveId === expectedMove);
if (movesetMoves.length !== 1) {
return {
pass: false,
message: () =>
`Expected MoveId.${moveStr} to appear in ${pkmName}'s moveset exactly once, but got ${movesetMoves.length} times!`,
expected: expectedMove,
actual: received.getMoveset(),
};
}
const move = movesetMoves[0]; // will be the only move in the array
let ppStr: string = ppUsed.toString();
if (typeof ppUsed === "string") {
ppStr = "all its";
ppUsed = move.getMovePp();
}
const pass = move.ppUsed === ppUsed;
return {
pass,
message: () =>
pass
? `Expected ${pkmName}'s ${moveStr} to NOT have used ${ppStr} PP, but it did!`
: `Expected ${pkmName}'s ${moveStr} to have used ${ppStr} PP, but got ${move.ppUsed} instead!`,
expected: ppUsed,
actual: move.ppUsed,
};
}

View File

@ -0,0 +1,62 @@
/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */
import type { GameManager } from "#test/test-utils/game-manager";
/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */
import { WeatherType } from "#enums/weather-type";
import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils";
import { toTitleCase } from "#utils/strings";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/**
* Matcher that checks if the {@linkcode WeatherType} is as expected
* @param received - The object to check. Expects an instance of {@linkcode GameManager}.
* @param expectedWeatherType - The expected {@linkcode WeatherType}
* @returns Whether the matcher passed
*/
export function toHaveWeather(
this: MatcherState,
received: unknown,
expectedWeatherType: WeatherType,
): SyncExpectationResult {
if (!isGameManagerInstance(received)) {
return {
pass: false,
message: () => `Expected GameManager, but got ${receivedStr(received)}!`,
};
}
if (!received.scene?.arena) {
return {
pass: false,
message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`,
};
}
const actual = received.scene.arena.getWeatherType();
const pass = actual === expectedWeatherType;
const actualStr = toWeatherStr(actual);
const expectedStr = toWeatherStr(expectedWeatherType);
return {
pass,
message: () =>
pass
? `Expected Arena to NOT have ${expectedStr} weather active, but it did!`
: `Expected Arena to have ${expectedStr} weather active, but got ${actualStr} instead!`,
expected: expectedWeatherType,
actual,
};
}
/**
* Get a human readable representation of the current {@linkcode WeatherType}.
* @param weatherType - The {@linkcode WeatherType} to transform
* @returns A human readable string
*/
function toWeatherStr(weatherType: WeatherType) {
if (weatherType === WeatherType.NONE) {
return "no weather";
}
return toTitleCase(WeatherType[weatherType]);
}

View File

@ -0,0 +1,183 @@
import { getStatKey, type Stat } from "#enums/stat";
import type { EnumOrObject, NormalEnum, TSNumericEnum } from "#types/enum-types";
import type { ObjectValues } from "#types/type-helpers";
import { enumValueToKey } from "#utils/enums";
import { toTitleCase } from "#utils/strings";
import type { MatcherState } from "@vitest/expect";
import i18next from "i18next";
type Casing = "Preserve" | "Title";
interface getEnumStrOptions {
/**
* A string denoting the casing method to use.
* @defaultValue "Preserve"
*/
casing?: Casing;
/**
* If present, will be prepended to the beginning of the enum string.
*/
prefix?: string;
/**
* If present, will be added to the end of the enum string.
*/
suffix?: string;
}
/**
* Return the name of an enum member or const object value, alongside its corresponding value.
* @param obj - The {@linkcode EnumOrObject} to source reverse mappings from
* @param enums - One of {@linkcode obj}'s values
* @param casing - A string denoting the casing method to use; default `Preserve`
* @param prefix - An optional string to be prepended to the enum's string representation
* @param suffix - An optional string to be appended to the enum's string representation
* @returns The stringified representation of `val` as dictated by the options.
* @example
* ```ts
* enum fakeEnum {
* ONE: 1,
* TWO: 2,
* THREE: 3,
* }
* getEnumStr(fakeEnum, fakeEnum.ONE); // Output: "ONE (=1)"
* getEnumStr(fakeEnum, fakeEnum.TWO, {casing: "Title", prefix: "fakeEnum.", suffix: "!!!"}); // Output: "fakeEnum.TWO!!! (=2)"
* ```
*/
export function getEnumStr<E extends EnumOrObject>(
obj: E,
val: ObjectValues<E>,
{ casing = "Preserve", prefix = "", suffix = "" }: getEnumStrOptions = {},
): string {
let casingFunc: ((s: string) => string) | undefined;
switch (casing) {
case "Preserve":
break;
case "Title":
casingFunc = toTitleCase;
break;
}
let stringPart =
obj[val] !== undefined
? // TS reverse mapped enum
(obj[val] as string)
: // Normal enum/`const object`
(enumValueToKey(obj as NormalEnum<E>, val) as string);
if (casingFunc) {
stringPart = casingFunc(stringPart);
}
return `${prefix}${stringPart}${suffix} (=${val})`;
}
/**
* Convert an array of enums or `const object`s into a readable string version.
* @param obj - The {@linkcode EnumOrObject} to source reverse mappings from
* @param enums - An array of {@linkcode obj}'s values
* @returns The stringified representation of `enums`.
* @example
* ```ts
* enum fakeEnum {
* ONE: 1,
* TWO: 2,
* THREE: 3,
* }
* console.log(stringifyEnumArray(fakeEnum, [fakeEnum.ONE, fakeEnum.TWO, fakeEnum.THREE])); // Output: "[ONE, TWO, THREE] (=[1, 2, 3])"
* ```
*/
export function stringifyEnumArray<E extends EnumOrObject>(obj: E, enums: E[keyof E][]): string {
if (obj.length === 0) {
return "[]";
}
const vals = enums.slice();
/** An array of string names */
let names: string[];
if (obj[enums[0]] !== undefined) {
// Reverse mapping exists - `obj` is a `TSNumericEnum` and its reverse mapped counterparts are strings
names = enums.map(e => (obj as TSNumericEnum<E>)[e] as string);
} else {
// No reverse mapping exists means `obj` is a `NormalEnum`.
// NB: This (while ugly) should be more ergonomic than doing a repeated lookup for large `const object`s
// as the `enums` array should be significantly shorter than the corresponding enum type.
names = [];
for (const [k, v] of Object.entries(obj as NormalEnum<E>)) {
if (names.length === enums.length) {
// No more names to get
break;
}
// Find all matches for the given enum, assigning their keys to the names array
findIndices(enums, v).forEach(matchIndex => {
names[matchIndex] = k;
});
}
}
return `[${names.join(", ")}] (=[${vals.join(", ")}])`;
}
/**
* Return the indices of all occurrences of a value in an array.
* @param arr - The array to search
* @param searchElement - The value to locate in the array
* @param fromIndex - The array index at which to begin the search. If fromIndex is omitted, the
* search starts at index 0
*/
function findIndices<T>(arr: T[], searchElement: T, fromIndex = 0): number[] {
const indices: number[] = [];
const arrSliced = arr.slice(fromIndex);
for (const [index, value] of arrSliced.entries()) {
if (value === searchElement) {
indices.push(index);
}
}
return indices;
}
/**
* Convert a number into an English ordinal
* @param num - The number to convert into an ordinal
* @returns The ordinal representation of {@linkcode num}.
* @example
* ```ts
* console.log(getOrdinal(1)); // Output: "1st"
* console.log(getOrdinal(12)); // Output: "12th"
* console.log(getOrdinal(24)); // Output: "24th"
* ```
*/
export function getOrdinal(num: number): string {
const tens = num % 10;
const hundreds = num % 100;
if (tens === 1 && hundreds !== 11) {
return num + "st";
}
if (tens === 2 && hundreds !== 12) {
return num + "nd";
}
if (tens === 3 && hundreds !== 13) {
return num + "rd";
}
return num + "th";
}
/**
* Get the localized name of a {@linkcode Stat}.
* @param s - The {@linkcode Stat} to check
* @returns - The proper name for s, retrieved from the translations.
*/
export function getStatName(s: Stat): string {
return i18next.t(getStatKey(s));
}
/**
* Convert an object into a oneline diff to be shown in an error message.
* @param obj - The object to return the oneline diff of
* @returns The updated diff
*/
export function getOnelineDiffStr(this: MatcherState, obj: unknown): string {
return this.utils
.stringify(obj, undefined, { maxLength: 35, indent: 0, printBasicPrototype: false })
.replace(/\n/g, " ") // Replace newlines with spaces
.replace(/,(\s*)}$/g, "$1}");
}

View File

@ -1,3 +1,5 @@
import { Pokemon } from "#field/pokemon";
import type { GameManager } from "#test/test-utils/game-manager";
import i18next, { type ParseKeys } from "i18next";
import { vi } from "vitest";
@ -29,3 +31,54 @@ export function arrayOfRange(start: number, end: number) {
export function getApiBaseUrl() {
return import.meta.env.VITE_SERVER_URL ?? "http://localhost:8001";
}
type TypeOfResult = "undefined" | "object" | "boolean" | "number" | "bigint" | "string" | "symbol" | "function";
/**
* Helper to determine the actual type of the received object as human readable string
* @param received - The received object
* @returns A human readable string of the received object (type)
*/
export function receivedStr(received: unknown, expectedType: TypeOfResult = "object"): string {
if (received === null) {
return "null";
}
if (received === undefined) {
return "undefined";
}
if (typeof received !== expectedType) {
return typeof received;
}
if (expectedType === "object") {
return received.constructor.name;
}
return "unknown";
}
/**
* Helper to check if the received object is an {@linkcode object}
* @param received - The object to check
* @returns Whether the object is an {@linkcode object}.
*/
function isObject(received: unknown): received is object {
return received !== null && typeof received === "object";
}
/**
* Helper function to check if a given object is a {@linkcode Pokemon}.
* @param received - The object to check
* @return Whether `received` is a {@linkcode Pokemon} instance.
*/
export function isPokemonInstance(received: unknown): received is Pokemon {
return isObject(received) && received instanceof Pokemon;
}
/**
* Checks if an object is a {@linkcode GameManager} instance
* @param received - The object to check
* @returns Whether the object is a {@linkcode GameManager} instance.
*/
export function isGameManagerInstance(received: unknown): received is GameManager {
return isObject(received) && (received as GameManager).constructor.name === "GameManager";
}

View File

@ -0,0 +1,38 @@
import type { AtLeastOne } from "#types/type-helpers";
import { describe, it } from "node:test";
import { expectTypeOf } from "vitest";
type fakeObj = {
foo: number;
bar: string;
baz: number | string;
};
type optionalObj = {
foo: number;
bar: string;
baz?: number | string;
};
describe("AtLeastOne", () => {
it("should accept an object with at least 1 of its defined parameters", () => {
expectTypeOf<{ foo: number }>().toExtend<AtLeastOne<fakeObj>>();
expectTypeOf<{ bar: string }>().toExtend<AtLeastOne<fakeObj>>();
expectTypeOf<{ baz: number | string }>().toExtend<AtLeastOne<fakeObj>>();
});
it("should convert to a partial intersection with the union of all individual single properties", () => {
expectTypeOf<AtLeastOne<fakeObj>>().branded.toEqualTypeOf<
Partial<fakeObj> & ({ foo: number } | { bar: string } | { baz: number | string })
>();
});
it("should treat optional properties as required", () => {
expectTypeOf<AtLeastOne<fakeObj>>().branded.toEqualTypeOf<AtLeastOne<optionalObj>>();
});
it("should not accept empty objects, even if optional properties are present", () => {
expectTypeOf<Record<string, never>>().not.toExtend<AtLeastOne<fakeObj>>();
expectTypeOf<Record<string, never>>().not.toExtend<AtLeastOne<optionalObj>>();
});
});