Compare commits

..

No commits in common. "db0c43e367f56f2e7c2600ef4915c2ff2c10bab9" and "5bfa2ece19057a4ecd529d66c6251cfc2a2e5ff3" have entirely different histories.

41 changed files with 655 additions and 553 deletions

View File

@ -29,7 +29,6 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.0.0", "@biomejs/biome": "2.0.0",
"@ls-lint/ls-lint": "2.3.1", "@ls-lint/ls-lint": "2.3.1",
"@types/crypto-js": "^4.2.0",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/node": "^22.16.5", "@types/node": "^22.16.5",
"@vitest/coverage-istanbul": "^3.2.4", "@vitest/coverage-istanbul": "^3.2.4",

View File

@ -48,9 +48,6 @@ importers:
'@ls-lint/ls-lint': '@ls-lint/ls-lint':
specifier: 2.3.1 specifier: 2.3.1
version: 2.3.1 version: 2.3.1
'@types/crypto-js':
specifier: ^4.2.0
version: 4.2.2
'@types/jsdom': '@types/jsdom':
specifier: ^21.1.7 specifier: ^21.1.7
version: 21.1.7 version: 21.1.7
@ -721,9 +718,6 @@ packages:
'@types/cookie@0.6.0': '@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
'@types/crypto-js@4.2.2':
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
'@types/deep-eql@4.0.2': '@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@ -2531,8 +2525,6 @@ snapshots:
'@types/cookie@0.6.0': {} '@types/cookie@0.6.0': {}
'@types/crypto-js@4.2.2': {}
'@types/deep-eql@4.0.2': {} '@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}

View File

@ -17,42 +17,45 @@ export function initLoggedInUser(): void {
}; };
} }
export async function updateUserInfo(): Promise<[boolean, number]> { export function updateUserInfo(): Promise<[boolean, number]> {
if (bypassLogin) { return new Promise<[boolean, number]>(resolve => {
loggedInUser = { if (bypassLogin) {
username: "Guest", loggedInUser = {
lastSessionSlot: -1, username: "Guest",
discordId: "", lastSessionSlot: -1,
googleId: "", discordId: "",
hasAdminRole: false, googleId: "",
}; hasAdminRole: false,
let lastSessionSlot = -1; };
for (let s = 0; s < 5; s++) { let lastSessionSlot = -1;
if (localStorage.getItem(`sessionData${s ? s : ""}_${loggedInUser.username}`)) { for (let s = 0; s < 5; s++) {
lastSessionSlot = s; if (localStorage.getItem(`sessionData${s ? s : ""}_${loggedInUser.username}`)) {
break; lastSessionSlot = s;
} break;
}
loggedInUser.lastSessionSlot = lastSessionSlot;
// Migrate old data from before the username was appended
["data", "sessionData", "sessionData1", "sessionData2", "sessionData3", "sessionData4"].forEach(d => {
const lsItem = localStorage.getItem(d);
if (lsItem && !!loggedInUser?.username) {
const lsUserItem = localStorage.getItem(`${d}_${loggedInUser.username}`);
if (lsUserItem) {
localStorage.setItem(`${d}_${loggedInUser.username}_bak`, lsUserItem);
} }
localStorage.setItem(`${d}_${loggedInUser.username}`, lsItem);
localStorage.removeItem(d);
} }
loggedInUser.lastSessionSlot = lastSessionSlot;
// Migrate old data from before the username was appended
["data", "sessionData", "sessionData1", "sessionData2", "sessionData3", "sessionData4"].map(d => {
const lsItem = localStorage.getItem(d);
if (lsItem && !!loggedInUser?.username) {
const lsUserItem = localStorage.getItem(`${d}_${loggedInUser.username}`);
if (lsUserItem) {
localStorage.setItem(`${d}_${loggedInUser.username}_bak`, lsUserItem);
}
localStorage.setItem(`${d}_${loggedInUser.username}`, lsItem);
localStorage.removeItem(d);
}
});
return resolve([true, 200]);
}
pokerogueApi.account.getInfo().then(([accountInfo, status]) => {
if (!accountInfo) {
resolve([false, status]);
return;
}
loggedInUser = accountInfo;
resolve([true, 200]);
}); });
return [true, 200]; });
}
const [accountInfo, status] = await pokerogueApi.account.getInfo();
if (!accountInfo) {
return [false, status];
}
loggedInUser = accountInfo;
return [true, 200];
} }

View File

@ -27,7 +27,13 @@ import { UiInputs } from "#app/ui-inputs";
import { biomeDepths, getBiomeName } from "#balance/biomes"; import { biomeDepths, getBiomeName } from "#balance/biomes";
import { pokemonPrevolutions } from "#balance/pokemon-evolutions"; import { pokemonPrevolutions } from "#balance/pokemon-evolutions";
import { FRIENDSHIP_GAIN_FROM_BATTLE } from "#balance/starters"; import { FRIENDSHIP_GAIN_FROM_BATTLE } from "#balance/starters";
import { initCommonAnims, initMoveAnim, loadCommonAnimAssets, loadMoveAnimAssets } from "#data/battle-anims"; import {
initCommonAnims,
initMoveAnim,
loadCommonAnimAssets,
loadMoveAnimAssets,
populateAnims,
} from "#data/battle-anims";
import { allAbilities, allMoves, allSpecies, modifierTypes } from "#data/data-lists"; import { allAbilities, allMoves, allSpecies, modifierTypes } from "#data/data-lists";
import { battleSpecDialogue } from "#data/dialogue"; import { battleSpecDialogue } from "#data/dialogue";
import type { SpeciesFormChangeTrigger } from "#data/form-change-triggers"; import type { SpeciesFormChangeTrigger } from "#data/form-change-triggers";
@ -382,6 +388,7 @@ export class BattleScene extends SceneBase {
const defaultMoves = [MoveId.TACKLE, MoveId.TAIL_WHIP, MoveId.FOCUS_ENERGY, MoveId.STRUGGLE]; const defaultMoves = [MoveId.TACKLE, MoveId.TAIL_WHIP, MoveId.FOCUS_ENERGY, MoveId.STRUGGLE];
await Promise.all([ await Promise.all([
populateAnims(),
this.initVariantData(), this.initVariantData(),
initCommonAnims().then(() => loadCommonAnimAssets(true)), initCommonAnims().then(() => loadCommonAnimAssets(true)),
Promise.all(defaultMoves.map(m => initMoveAnim(m))).then(() => loadMoveAnimAssets(defaultMoves, true)), Promise.all(defaultMoves.map(m => initMoveAnim(m))).then(() => loadMoveAnimAssets(defaultMoves, true)),

View File

@ -404,18 +404,22 @@ export const chargeAnims = new Map<ChargeAnim, AnimConfig | [AnimConfig, AnimCon
export const commonAnims = new Map<CommonAnim, AnimConfig>(); export const commonAnims = new Map<CommonAnim, AnimConfig>();
export const encounterAnims = new Map<EncounterAnim, AnimConfig>(); export const encounterAnims = new Map<EncounterAnim, AnimConfig>();
export async function initCommonAnims(): Promise<void> { export function initCommonAnims(): Promise<void> {
const commonAnimFetches: Promise<Map<CommonAnim, AnimConfig>>[] = []; return new Promise(resolve => {
for (const commonAnimName of getEnumKeys(CommonAnim)) { const commonAnimNames = getEnumKeys(CommonAnim);
const commonAnimId = CommonAnim[commonAnimName]; const commonAnimIds = getEnumValues(CommonAnim);
commonAnimFetches.push( const commonAnimFetches: Promise<Map<CommonAnim, AnimConfig>>[] = [];
globalScene for (let ca = 0; ca < commonAnimIds.length; ca++) {
.cachedFetch(`./battle-anims/common-${toKebabCase(commonAnimName)}.json`) const commonAnimId = commonAnimIds[ca];
.then(response => response.json()) commonAnimFetches.push(
.then(cas => commonAnims.set(commonAnimId, new AnimConfig(cas))), globalScene
); .cachedFetch(`./battle-anims/common-${toKebabCase(commonAnimNames[ca])}.json`)
} .then(response => response.json())
await Promise.allSettled(commonAnimFetches); .then(cas => commonAnims.set(commonAnimId, new AnimConfig(cas))),
);
}
Promise.allSettled(commonAnimFetches).then(() => resolve());
});
} }
export function initMoveAnim(move: MoveId): Promise<void> { export function initMoveAnim(move: MoveId): Promise<void> {
@ -1392,3 +1396,279 @@ export class EncounterBattleAnim extends BattleAnim {
return this.oppAnim; return this.oppAnim;
} }
} }
export async function populateAnims() {
const commonAnimNames = getEnumKeys(CommonAnim).map(k => k.toLowerCase());
const commonAnimMatchNames = commonAnimNames.map(k => k.replace(/_/g, ""));
const commonAnimIds = getEnumValues(CommonAnim);
const chargeAnimNames = getEnumKeys(ChargeAnim).map(k => k.toLowerCase());
const chargeAnimMatchNames = chargeAnimNames.map(k => k.replace(/_/g, " "));
const chargeAnimIds = getEnumValues(ChargeAnim);
const commonNamePattern = /name: (?:Common:)?(Opp )?(.*)/;
const moveNameToId = {};
// Exclude MoveId.NONE;
for (const move of getEnumValues(MoveId).slice(1)) {
// KARATE_CHOP => KARATECHOP
const moveName = MoveId[move].toUpperCase().replace(/_/g, "");
moveNameToId[moveName] = move;
}
const seNames: string[] = []; //(await fs.readdir('./public/audio/se/battle_anims/')).map(se => se.toString());
const animsData: any[] = []; //battleAnimRawData.split('!ruby/array:PBAnimation').slice(1); // TODO: add a proper type
for (let a = 0; a < animsData.length; a++) {
const fields = animsData[a].split("@").slice(1);
const nameField = fields.find(f => f.startsWith("name: "));
let isOppMove: boolean | undefined;
let commonAnimId: CommonAnim | undefined;
let chargeAnimId: ChargeAnim | undefined;
if (!nameField.startsWith("name: Move:") && !(isOppMove = nameField.startsWith("name: OppMove:"))) {
const nameMatch = commonNamePattern.exec(nameField)!; // TODO: is this bang correct?
const name = nameMatch[2].toLowerCase();
if (commonAnimMatchNames.indexOf(name) > -1) {
commonAnimId = commonAnimIds[commonAnimMatchNames.indexOf(name)];
} else if (chargeAnimMatchNames.indexOf(name) > -1) {
isOppMove = nameField.startsWith("name: Opp ");
chargeAnimId = chargeAnimIds[chargeAnimMatchNames.indexOf(name)];
}
}
const nameIndex = nameField.indexOf(":", 5) + 1;
const animName = nameField.slice(nameIndex, nameField.indexOf("\n", nameIndex));
if (!moveNameToId.hasOwnProperty(animName) && !commonAnimId && !chargeAnimId) {
continue;
}
const anim = commonAnimId || chargeAnimId ? new AnimConfig() : new AnimConfig();
if (anim instanceof AnimConfig) {
(anim as AnimConfig).id = moveNameToId[animName];
}
if (commonAnimId) {
commonAnims.set(commonAnimId, anim);
} else if (chargeAnimId) {
chargeAnims.set(chargeAnimId, !isOppMove ? anim : [chargeAnims.get(chargeAnimId) as AnimConfig, anim]);
} else {
moveAnims.set(
moveNameToId[animName],
!isOppMove ? (anim as AnimConfig) : [moveAnims.get(moveNameToId[animName]) as AnimConfig, anim as AnimConfig],
);
}
for (let f = 0; f < fields.length; f++) {
const field = fields[f];
const fieldName = field.slice(0, field.indexOf(":"));
const fieldData = field.slice(fieldName.length + 1, field.lastIndexOf("\n")).trim();
switch (fieldName) {
case "array": {
const framesData = fieldData.split(" - - - ").slice(1);
for (let fd = 0; fd < framesData.length; fd++) {
anim.frames.push([]);
const frameData = framesData[fd];
const focusFramesData = frameData.split(" - - ");
for (let tf = 0; tf < focusFramesData.length; tf++) {
const values = focusFramesData[tf].replace(/ {6}- /g, "").split("\n");
const targetFrame = new AnimFrame(
Number.parseFloat(values[0]),
Number.parseFloat(values[1]),
Number.parseFloat(values[2]),
Number.parseFloat(values[11]),
Number.parseFloat(values[3]),
Number.parseInt(values[4]) === 1,
Number.parseInt(values[6]) === 1,
Number.parseInt(values[5]),
Number.parseInt(values[7]),
Number.parseInt(values[8]),
Number.parseInt(values[12]),
Number.parseInt(values[13]),
Number.parseInt(values[14]),
Number.parseInt(values[15]),
Number.parseInt(values[16]),
Number.parseInt(values[17]),
Number.parseInt(values[18]),
Number.parseInt(values[19]),
Number.parseInt(values[21]),
Number.parseInt(values[22]),
Number.parseInt(values[23]),
Number.parseInt(values[24]),
Number.parseInt(values[20]) === 1,
Number.parseInt(values[25]),
Number.parseInt(values[26]) as AnimFocus,
);
anim.frames[fd].push(targetFrame);
}
}
break;
}
case "graphic": {
const graphic = fieldData !== "''" ? fieldData : "";
anim.graphic = graphic.indexOf(".") > -1 ? graphic.slice(0, fieldData.indexOf(".")) : graphic;
break;
}
case "timing": {
const timingEntries = fieldData.split("- !ruby/object:PBAnimTiming ").slice(1);
for (let t = 0; t < timingEntries.length; t++) {
const timingData = timingEntries[t]
.replace(/\n/g, " ")
.replace(/[ ]{2,}/g, " ")
.replace(/[a-z]+: ! '', /gi, "")
.replace(/name: (.*?),/, 'name: "$1",')
.replace(
/flashColor: !ruby\/object:Color { alpha: ([\d.]+), blue: ([\d.]+), green: ([\d.]+), red: ([\d.]+)}/,
"flashRed: $4, flashGreen: $3, flashBlue: $2, flashAlpha: $1",
);
const frameIndex = Number.parseInt(/frame: (\d+)/.exec(timingData)![1]); // TODO: is the bang correct?
let resourceName = /name: "(.*?)"/.exec(timingData)![1].replace("''", ""); // TODO: is the bang correct?
const timingType = Number.parseInt(/timingType: (\d)/.exec(timingData)![1]); // TODO: is the bang correct?
let timedEvent: AnimTimedEvent | undefined;
switch (timingType) {
case 0:
if (resourceName && resourceName.indexOf(".") === -1) {
let ext: string | undefined;
["wav", "mp3", "m4a"].every(e => {
if (seNames.indexOf(`${resourceName}.${e}`) > -1) {
ext = e;
return false;
}
return true;
});
if (!ext) {
ext = ".wav";
}
resourceName += `.${ext}`;
}
timedEvent = new AnimTimedSoundEvent(frameIndex, resourceName);
break;
case 1:
timedEvent = new AnimTimedAddBgEvent(frameIndex, resourceName.slice(0, resourceName.indexOf(".")));
break;
case 2:
timedEvent = new AnimTimedUpdateBgEvent(frameIndex, resourceName.slice(0, resourceName.indexOf(".")));
break;
}
if (!timedEvent) {
continue;
}
const propPattern = /([a-z]+): (.*?)(?:,|\})/gi;
let propMatch: RegExpExecArray;
while ((propMatch = propPattern.exec(timingData)!)) {
// TODO: is this bang correct?
const prop = propMatch[1];
let value: any = propMatch[2];
switch (prop) {
case "bgX":
case "bgY":
value = Number.parseFloat(value);
break;
case "volume":
case "pitch":
case "opacity":
case "colorRed":
case "colorGreen":
case "colorBlue":
case "colorAlpha":
case "duration":
case "flashScope":
case "flashRed":
case "flashGreen":
case "flashBlue":
case "flashAlpha":
case "flashDuration":
value = Number.parseInt(value);
break;
}
if (timedEvent.hasOwnProperty(prop)) {
timedEvent[prop] = value;
}
}
if (!anim.frameTimedEvents.has(frameIndex)) {
anim.frameTimedEvents.set(frameIndex, []);
}
anim.frameTimedEvents.get(frameIndex)!.push(timedEvent); // TODO: is this bang correct?
}
break;
}
case "position":
anim.position = Number.parseInt(fieldData);
break;
case "hue":
anim.hue = Number.parseInt(fieldData);
break;
}
}
}
// biome-ignore lint/correctness/noUnusedVariables: used in commented code
const animReplacer = (k, v) => {
if (k === "id" && !v) {
return undefined;
}
if (v instanceof Map) {
return Object.fromEntries(v);
}
if (v instanceof AnimTimedEvent) {
v["eventType"] = v.getEventType();
}
return v;
};
const animConfigProps = ["id", "graphic", "frames", "frameTimedEvents", "position", "hue"];
const animFrameProps = [
"x",
"y",
"zoomX",
"zoomY",
"angle",
"mirror",
"visible",
"blendType",
"target",
"graphicFrame",
"opacity",
"color",
"tone",
"flash",
"locked",
"priority",
"focus",
];
const propSets = [animConfigProps, animFrameProps];
// biome-ignore lint/correctness/noUnusedVariables: used in commented code
const animComparator = (a: Element, b: Element) => {
let props: string[];
for (let p = 0; p < propSets.length; p++) {
props = propSets[p];
// @ts-expect-error TODO
const ai = props.indexOf(a.key);
if (ai === -1) {
continue;
}
// @ts-expect-error TODO
const bi = props.indexOf(b.key);
return ai < bi ? -1 : ai > bi ? 1 : 0;
}
return 0;
};
/*for (let ma of moveAnims.keys()) {
const data = moveAnims.get(ma);
(async () => {
await fs.writeFile(`../public/battle-anims/${Moves[ma].toLowerCase().replace(/\_/g, '-')}.json`, stringify(data, { replacer: animReplacer, cmp: animComparator, space: ' ' }));
})();
}
for (let ca of chargeAnims.keys()) {
const data = chargeAnims.get(ca);
(async () => {
await fs.writeFile(`../public/battle-anims/${chargeAnimNames[chargeAnimIds.indexOf(ca)].replace(/\_/g, '-')}.json`, stringify(data, { replacer: animReplacer, cmp: animComparator, space: ' ' }));
})();
}
for (let cma of commonAnims.keys()) {
const data = commonAnims.get(cma);
(async () => {
await fs.writeFile(`../public/battle-anims/common-${commonAnimNames[commonAnimIds.indexOf(cma)].replace(/\_/g, '-')}.json`, stringify(data, { replacer: animReplacer, cmp: animComparator, space: ' ' }));
})();
}*/
}

View File

@ -11,7 +11,7 @@ import { BooleanHolder, toDmgValue } from "#utils/common";
* These are the moves assigned to a {@linkcode Pokemon} object. * These are the moves assigned to a {@linkcode Pokemon} object.
* It links to {@linkcode Move} class via the move ID. * It links to {@linkcode Move} class via the move ID.
* Compared to {@linkcode Move}, this class also tracks things like * Compared to {@linkcode Move}, this class also tracks things like
* PP Ups received, PP used, etc. * PP Ups recieved, PP used, etc.
* @see {@linkcode isUsable} - checks if move is restricted, out of PP, or not implemented. * @see {@linkcode isUsable} - checks if move is restricted, out of PP, or not implemented.
* @see {@linkcode getMove} - returns {@linkcode Move} object by looking it up via ID. * @see {@linkcode getMove} - returns {@linkcode Move} object by looking it up via ID.
* @see {@linkcode usePp} - removes a point of PP from the move. * @see {@linkcode usePp} - removes a point of PP from the move.

View File

@ -99,7 +99,6 @@ export const DarkDealEncounter: MysteryEncounter = MysteryEncounterBuilder.withE
MysteryEncounterType.DARK_DEAL, MysteryEncounterType.DARK_DEAL,
) )
.withEncounterTier(MysteryEncounterTier.ROGUE) .withEncounterTier(MysteryEncounterTier.ROGUE)
.withDisallowedChallenges(Challenges.HARDCORE)
.withIntroSpriteConfigs([ .withIntroSpriteConfigs([
{ {
spriteKey: "dark_deal_scientist", spriteKey: "dark_deal_scientist",

View File

@ -673,8 +673,6 @@ export async function catchPokemon(
globalScene.gameData.updateSpeciesDexIvs(pokemon.species.getRootSpeciesId(true), pokemon.ivs); globalScene.gameData.updateSpeciesDexIvs(pokemon.species.getRootSpeciesId(true), pokemon.ivs);
return new Promise(resolve => { return new Promise(resolve => {
const addStatus = new BooleanHolder(true);
applyChallenges(ChallengeType.POKEMON_ADD_TO_PARTY, pokemon, addStatus);
const doPokemonCatchMenu = () => { const doPokemonCatchMenu = () => {
const end = () => { const end = () => {
// Ensure the pokemon is in the enemy party in all situations // Ensure the pokemon is in the enemy party in all situations
@ -710,7 +708,9 @@ export async function catchPokemon(
}); });
}; };
Promise.all([pokemon.hideInfo(), globalScene.gameData.setPokemonCaught(pokemon)]).then(() => { Promise.all([pokemon.hideInfo(), globalScene.gameData.setPokemonCaught(pokemon)]).then(() => {
if (!(isObtain || addStatus.value)) { const addStatus = new BooleanHolder(true);
applyChallenges(ChallengeType.POKEMON_ADD_TO_PARTY, pokemon, addStatus);
if (!addStatus.value) {
removePokemon(); removePokemon();
end(); end();
return; return;
@ -807,16 +807,10 @@ export async function catchPokemon(
}; };
if (showCatchObtainMessage) { if (showCatchObtainMessage) {
let catchMessage: string;
if (isObtain) {
catchMessage = "battle:pokemonObtained";
} else if (addStatus.value) {
catchMessage = "battle:pokemonCaught";
} else {
catchMessage = "battle:pokemonCaughtButChallenge";
}
globalScene.ui.showText( globalScene.ui.showText(
i18next.t(catchMessage, { pokemonName: pokemon.getNameToRender() }), i18next.t(isObtain ? "battle:pokemonObtained" : "battle:pokemonCaught", {
pokemonName: pokemon.getNameToRender(),
}),
null, null,
doPokemonCatchMenu, doPokemonCatchMenu,
0, 0,

View File

@ -253,11 +253,8 @@ export class AttemptCapturePhase extends PokemonPhase {
globalScene.gameData.updateSpeciesDexIvs(pokemon.species.getRootSpeciesId(true), pokemon.ivs); globalScene.gameData.updateSpeciesDexIvs(pokemon.species.getRootSpeciesId(true), pokemon.ivs);
const addStatus = new BooleanHolder(true);
applyChallenges(ChallengeType.POKEMON_ADD_TO_PARTY, pokemon, addStatus);
globalScene.ui.showText( globalScene.ui.showText(
i18next.t(addStatus.value ? "battle:pokemonCaught" : "battle:pokemonCaughtButChallenge", { i18next.t("battle:pokemonCaught", {
pokemonName: getPokemonNameWithAffix(pokemon), pokemonName: getPokemonNameWithAffix(pokemon),
}), }),
null, null,
@ -293,6 +290,8 @@ export class AttemptCapturePhase extends PokemonPhase {
}); });
}; };
Promise.all([pokemon.hideInfo(), globalScene.gameData.setPokemonCaught(pokemon)]).then(() => { Promise.all([pokemon.hideInfo(), globalScene.gameData.setPokemonCaught(pokemon)]).then(() => {
const addStatus = new BooleanHolder(true);
applyChallenges(ChallengeType.POKEMON_ADD_TO_PARTY, pokemon, addStatus);
if (!addStatus.value) { if (!addStatus.value) {
removePokemon(); removePokemon();
end(); end();

View File

@ -16,10 +16,8 @@ export class SelectBiomePhase extends BattlePhase {
globalScene.resetSeed(); globalScene.resetSeed();
const gameMode = globalScene.gameMode;
const currentBiome = globalScene.arena.biomeType; const currentBiome = globalScene.arena.biomeType;
const currentWaveIndex = globalScene.currentBattle.waveIndex; const nextWaveIndex = globalScene.currentBattle.waveIndex + 1;
const nextWaveIndex = currentWaveIndex + 1;
const setNextBiome = (nextBiome: BiomeId) => { const setNextBiome = (nextBiome: BiomeId) => {
if (nextWaveIndex % 10 === 1) { if (nextWaveIndex % 10 === 1) {
@ -28,15 +26,6 @@ export class SelectBiomePhase extends BattlePhase {
applyChallenges(ChallengeType.PARTY_HEAL, healStatus); applyChallenges(ChallengeType.PARTY_HEAL, healStatus);
if (healStatus.value) { if (healStatus.value) {
globalScene.phaseManager.unshiftNew("PartyHealPhase", false); globalScene.phaseManager.unshiftNew("PartyHealPhase", false);
} else {
globalScene.phaseManager.unshiftNew(
"SelectModifierPhase",
undefined,
undefined,
gameMode.isFixedBattle(currentWaveIndex)
? gameMode.getFixedBattle(currentWaveIndex).customModifierRewardSettings
: undefined,
);
} }
} }
globalScene.phaseManager.unshiftNew("SwitchBiomePhase", nextBiome); globalScene.phaseManager.unshiftNew("SwitchBiomePhase", nextBiome);
@ -44,12 +33,12 @@ export class SelectBiomePhase extends BattlePhase {
}; };
if ( if (
(gameMode.isClassic && gameMode.isWaveFinal(nextWaveIndex + 9)) || (globalScene.gameMode.isClassic && globalScene.gameMode.isWaveFinal(nextWaveIndex + 9)) ||
(gameMode.isDaily && gameMode.isWaveFinal(nextWaveIndex)) || (globalScene.gameMode.isDaily && globalScene.gameMode.isWaveFinal(nextWaveIndex)) ||
(gameMode.hasShortBiomes && !(nextWaveIndex % 50)) (globalScene.gameMode.hasShortBiomes && !(nextWaveIndex % 50))
) { ) {
setNextBiome(BiomeId.END); setNextBiome(BiomeId.END);
} else if (gameMode.hasRandomBiomes) { } else if (globalScene.gameMode.hasRandomBiomes) {
setNextBiome(this.generateNextBiome(nextWaveIndex)); setNextBiome(this.generateNextBiome(nextWaveIndex));
} else if (Array.isArray(biomeLinks[currentBiome])) { } else if (Array.isArray(biomeLinks[currentBiome])) {
const biomes: BiomeId[] = (biomeLinks[currentBiome] as (BiomeId | [BiomeId, number])[]) const biomes: BiomeId[] = (biomeLinks[currentBiome] as (BiomeId | [BiomeId, number])[])
@ -84,6 +73,9 @@ export class SelectBiomePhase extends BattlePhase {
} }
generateNextBiome(waveIndex: number): BiomeId { generateNextBiome(waveIndex: number): BiomeId {
return waveIndex % 50 === 0 ? BiomeId.END : globalScene.generateRandomBiome(waveIndex); if (!(waveIndex % 50)) {
return BiomeId.END;
}
return globalScene.generateRandomBiome(waveIndex);
} }
} }

View File

@ -3,9 +3,13 @@ import { globalScene } from "#app/global-scene";
import { modifierTypes } from "#data/data-lists"; import { modifierTypes } from "#data/data-lists";
import { BattleType } from "#enums/battle-type"; import { BattleType } from "#enums/battle-type";
import type { BattlerIndex } from "#enums/battler-index"; import type { BattlerIndex } from "#enums/battler-index";
import { ChallengeType } from "#enums/challenge-type";
import { ClassicFixedBossWaves } from "#enums/fixed-boss-waves"; import { ClassicFixedBossWaves } from "#enums/fixed-boss-waves";
import type { CustomModifierSettings } from "#modifiers/modifier-type";
import { handleMysteryEncounterVictory } from "#mystery-encounters/encounter-phase-utils"; import { handleMysteryEncounterVictory } from "#mystery-encounters/encounter-phase-utils";
import { PokemonPhase } from "#phases/pokemon-phase"; import { PokemonPhase } from "#phases/pokemon-phase";
import { applyChallenges } from "#utils/challenge-utils";
import { BooleanHolder } from "#utils/common";
export class VictoryPhase extends PokemonPhase { export class VictoryPhase extends PokemonPhase {
public readonly phaseName = "VictoryPhase"; public readonly phaseName = "VictoryPhase";
@ -45,19 +49,15 @@ export class VictoryPhase extends PokemonPhase {
if (globalScene.currentBattle.battleType === BattleType.TRAINER) { if (globalScene.currentBattle.battleType === BattleType.TRAINER) {
globalScene.phaseManager.pushNew("TrainerVictoryPhase"); globalScene.phaseManager.pushNew("TrainerVictoryPhase");
} }
if (globalScene.gameMode.isEndless || !globalScene.gameMode.isWaveFinal(globalScene.currentBattle.waveIndex)) {
const gameMode = globalScene.gameMode;
const currentWaveIndex = globalScene.currentBattle.waveIndex;
if (gameMode.isEndless || !gameMode.isWaveFinal(currentWaveIndex)) {
globalScene.phaseManager.pushNew("EggLapsePhase"); globalScene.phaseManager.pushNew("EggLapsePhase");
if (gameMode.isClassic) { if (globalScene.gameMode.isClassic) {
switch (currentWaveIndex) { switch (globalScene.currentBattle.waveIndex) {
case ClassicFixedBossWaves.RIVAL_1: case ClassicFixedBossWaves.RIVAL_1:
case ClassicFixedBossWaves.RIVAL_2: case ClassicFixedBossWaves.RIVAL_2:
// Get event modifiers for this wave // Get event modifiers for this wave
timedEventManager timedEventManager
.getFixedBattleEventRewards(currentWaveIndex) .getFixedBattleEventRewards(globalScene.currentBattle.waveIndex)
.map(r => globalScene.phaseManager.pushNew("ModifierRewardPhase", modifierTypes[r])); .map(r => globalScene.phaseManager.pushNew("ModifierRewardPhase", modifierTypes[r]));
break; break;
case ClassicFixedBossWaves.EVIL_BOSS_2: case ClassicFixedBossWaves.EVIL_BOSS_2:
@ -66,53 +66,59 @@ export class VictoryPhase extends PokemonPhase {
break; break;
} }
} }
if (currentWaveIndex % 10) { const healStatus = new BooleanHolder(globalScene.currentBattle.waveIndex % 10 === 0);
applyChallenges(ChallengeType.PARTY_HEAL, healStatus);
if (!healStatus.value) {
globalScene.phaseManager.pushNew( globalScene.phaseManager.pushNew(
"SelectModifierPhase", "SelectModifierPhase",
undefined, undefined,
undefined, undefined,
gameMode.isFixedBattle(currentWaveIndex) this.getFixedBattleCustomModifiers(),
? gameMode.getFixedBattle(currentWaveIndex).customModifierRewardSettings
: undefined,
); );
} else if (gameMode.isDaily) { } else if (globalScene.gameMode.isDaily) {
globalScene.phaseManager.pushNew("ModifierRewardPhase", modifierTypes.EXP_CHARM); globalScene.phaseManager.pushNew("ModifierRewardPhase", modifierTypes.EXP_CHARM);
if (currentWaveIndex > 10 && !gameMode.isWaveFinal(currentWaveIndex)) { if (
globalScene.currentBattle.waveIndex > 10 &&
!globalScene.gameMode.isWaveFinal(globalScene.currentBattle.waveIndex)
) {
globalScene.phaseManager.pushNew("ModifierRewardPhase", modifierTypes.GOLDEN_POKEBALL); globalScene.phaseManager.pushNew("ModifierRewardPhase", modifierTypes.GOLDEN_POKEBALL);
} }
} else { } else {
const superExpWave = !gameMode.isEndless ? (globalScene.offsetGym ? 0 : 20) : 10; const superExpWave = !globalScene.gameMode.isEndless ? (globalScene.offsetGym ? 0 : 20) : 10;
if (gameMode.isEndless && currentWaveIndex === 10) { if (globalScene.gameMode.isEndless && globalScene.currentBattle.waveIndex === 10) {
globalScene.phaseManager.pushNew("ModifierRewardPhase", modifierTypes.EXP_SHARE); globalScene.phaseManager.pushNew("ModifierRewardPhase", modifierTypes.EXP_SHARE);
} }
if (currentWaveIndex <= 750 && (currentWaveIndex <= 500 || currentWaveIndex % 30 === superExpWave)) { if (
globalScene.currentBattle.waveIndex <= 750 &&
(globalScene.currentBattle.waveIndex <= 500 || globalScene.currentBattle.waveIndex % 30 === superExpWave)
) {
globalScene.phaseManager.pushNew( globalScene.phaseManager.pushNew(
"ModifierRewardPhase", "ModifierRewardPhase",
currentWaveIndex % 30 !== superExpWave || currentWaveIndex > 250 globalScene.currentBattle.waveIndex % 30 !== superExpWave || globalScene.currentBattle.waveIndex > 250
? modifierTypes.EXP_CHARM ? modifierTypes.EXP_CHARM
: modifierTypes.SUPER_EXP_CHARM, : modifierTypes.SUPER_EXP_CHARM,
); );
} }
if (currentWaveIndex <= 150 && !(currentWaveIndex % 50)) { if (globalScene.currentBattle.waveIndex <= 150 && !(globalScene.currentBattle.waveIndex % 50)) {
globalScene.phaseManager.pushNew("ModifierRewardPhase", modifierTypes.GOLDEN_POKEBALL); globalScene.phaseManager.pushNew("ModifierRewardPhase", modifierTypes.GOLDEN_POKEBALL);
} }
if (gameMode.isEndless && !(currentWaveIndex % 50)) { if (globalScene.gameMode.isEndless && !(globalScene.currentBattle.waveIndex % 50)) {
globalScene.phaseManager.pushNew( globalScene.phaseManager.pushNew(
"ModifierRewardPhase", "ModifierRewardPhase",
!(currentWaveIndex % 250) ? modifierTypes.VOUCHER_PREMIUM : modifierTypes.VOUCHER_PLUS, !(globalScene.currentBattle.waveIndex % 250) ? modifierTypes.VOUCHER_PREMIUM : modifierTypes.VOUCHER_PLUS,
); );
globalScene.phaseManager.pushNew("AddEnemyBuffModifierPhase"); globalScene.phaseManager.pushNew("AddEnemyBuffModifierPhase");
} }
} }
if (gameMode.hasRandomBiomes || globalScene.isNewBiome()) { if (globalScene.gameMode.hasRandomBiomes || globalScene.isNewBiome()) {
globalScene.phaseManager.pushNew("SelectBiomePhase"); globalScene.phaseManager.pushNew("SelectBiomePhase");
} }
globalScene.phaseManager.pushNew("NewBattlePhase"); globalScene.phaseManager.pushNew("NewBattlePhase");
} else { } else {
globalScene.currentBattle.battleType = BattleType.CLEAR; globalScene.currentBattle.battleType = BattleType.CLEAR;
globalScene.score += gameMode.getClearScoreBonus(); globalScene.score += globalScene.gameMode.getClearScoreBonus();
globalScene.updateScoreText(); globalScene.updateScoreText();
globalScene.phaseManager.pushNew("GameOverPhase", true); globalScene.phaseManager.pushNew("GameOverPhase", true);
} }
@ -120,4 +126,18 @@ export class VictoryPhase extends PokemonPhase {
this.end(); this.end();
} }
/**
* If this wave is a fixed battle with special custom modifier rewards,
* will pass those settings to the upcoming {@linkcode SelectModifierPhase}`.
*/
getFixedBattleCustomModifiers(): CustomModifierSettings | undefined {
const gameMode = globalScene.gameMode;
const waveIndex = globalScene.currentBattle.waveIndex;
if (gameMode.isFixedBattle(waveIndex)) {
return gameMode.getFixedBattle(waveIndex).customModifierRewardSettings;
}
return undefined;
}
} }

View File

@ -56,15 +56,15 @@ export class PokerogueSessionSavedataApi extends ApiBase {
/** /**
* Update a session savedata. * Update a session savedata.
* @param params - The request to send * @param params The {@linkcode UpdateSessionSavedataRequest} to send
* @param rawSavedata - The raw, unencrypted savedata * @param rawSavedata The raw savedata (as `string`)
* @returns An error message if something went wrong * @returns An error message if something went wrong
*/ */
public async update(params: UpdateSessionSavedataRequest, rawSavedata: string): Promise<string> { public async update(params: UpdateSessionSavedataRequest, rawSavedata: string) {
try { try {
const urlSearchParams = this.toUrlSearchParams(params); const urlSearchParams = this.toUrlSearchParams(params);
const response = await this.doPost(`/savedata/session/update?${urlSearchParams}`, rawSavedata); const response = await this.doPost(`/savedata/session/update?${urlSearchParams}`, rawSavedata);
return await response.text(); return await response.text();
} catch (err) { } catch (err) {
console.warn("Could not update session savedata!", err); console.warn("Could not update session savedata!", err);

View File

@ -448,8 +448,6 @@ export function getAchievementDescription(localizationKey: string): string {
return i18next.t("achv:FLIP_STATS.description", { context: genderStr }); return i18next.t("achv:FLIP_STATS.description", { context: genderStr });
case "FLIP_INVERSE": case "FLIP_INVERSE":
return i18next.t("achv:FLIP_INVERSE.description", { context: genderStr }); return i18next.t("achv:FLIP_INVERSE.description", { context: genderStr });
case "NUZLOCKE":
return i18next.t("achv:NUZLOCKE.description", { context: genderStr });
case "BREEDERS_IN_SPACE": case "BREEDERS_IN_SPACE":
return i18next.t("achv:BREEDERS_IN_SPACE.description", { return i18next.t("achv:BREEDERS_IN_SPACE.description", {
context: genderStr, context: genderStr,

View File

@ -128,8 +128,7 @@ export interface SessionSaveData {
battleType: BattleType; battleType: BattleType;
trainer: TrainerData; trainer: TrainerData;
gameVersion: string; gameVersion: string;
/** The player-chosen name of the run */ runNameText: string;
name: string;
timestamp: number; timestamp: number;
challenges: ChallengeData[]; challenges: ChallengeData[];
mysteryEncounterType: MysteryEncounterType | -1; // Only defined when current wave is ME, mysteryEncounterType: MysteryEncounterType | -1; // Only defined when current wave is ME,
@ -987,45 +986,51 @@ export class GameData {
} }
async renameSession(slotId: number, newName: string): Promise<boolean> { async renameSession(slotId: number, newName: string): Promise<boolean> {
if (slotId < 0) { return new Promise(async resolve => {
return false; if (slotId < 0) {
} return resolve(false);
if (newName === "") { }
return true; const sessionData: SessionSaveData | null = await this.getSession(slotId);
}
const sessionData: SessionSaveData | null = await this.getSession(slotId);
if (!sessionData) { if (!sessionData) {
return false; return resolve(false);
} }
sessionData.name = newName; if (newName === "") {
// update timestamp by 1 to ensure the session is saved return resolve(true);
sessionData.timestamp += 1; }
const updatedDataStr = JSON.stringify(sessionData);
const encrypted = encrypt(updatedDataStr, bypassLogin);
const secretId = this.secretId;
const trainerId = this.trainerId;
if (bypassLogin) { sessionData.runNameText = newName;
localStorage.setItem( const updatedDataStr = JSON.stringify(sessionData);
`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, const encrypted = encrypt(updatedDataStr, bypassLogin);
encrypt(updatedDataStr, bypassLogin), const secretId = this.secretId;
); const trainerId = this.trainerId;
return true;
}
const response = await pokerogueApi.savedata.session.update( if (bypassLogin) {
{ slot: slotId, trainerId, secretId, clientSessionId }, localStorage.setItem(
updatedDataStr, `sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`,
); encrypt(updatedDataStr, bypassLogin),
);
if (response) { resolve(true);
return false; return;
} }
localStorage.setItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, encrypted); pokerogueApi.savedata.session
const success = await updateUserInfo(); .update({ slot: slotId, trainerId, secretId, clientSessionId }, encrypted)
return !(success !== null && !success); .then(error => {
if (error) {
console.error("Failed to update session name:", error);
resolve(false);
} else {
localStorage.setItem(`sessionData${slotId ? slotId : ""}_${loggedInUser?.username}`, encrypted);
updateUserInfo().then(success => {
if (success !== null && !success) {
return resolve(false);
}
});
resolve(true);
}
});
});
} }
loadSession(slotId: number, sessionData?: SessionSaveData): Promise<boolean> { loadSession(slotId: number, sessionData?: SessionSaveData): Promise<boolean> {

View File

@ -2142,12 +2142,7 @@ class PartyCancelButton extends Phaser.GameObjects.Container {
this.partyCancelPb = partyCancelPb; this.partyCancelPb = partyCancelPb;
const partyCancelText = addTextObject( const partyCancelText = addTextObject(-10, -7, i18next.t("partyUiHandler:cancel"), TextStyle.PARTY_CANCEL_BUTTON);
-10,
-7,
i18next.t("partyUiHandler:cancelButton"),
TextStyle.PARTY_CANCEL_BUTTON,
);
this.add(partyCancelText); this.add(partyCancelText);
} }

View File

@ -410,11 +410,6 @@ export class PokedexUiHandler extends MessageUiHandler {
new DropDownLabel(i18next.t("filterBar:hasHiddenAbility"), undefined, DropDownState.ON), new DropDownLabel(i18next.t("filterBar:hasHiddenAbility"), undefined, DropDownState.ON),
new DropDownLabel(i18next.t("filterBar:noHiddenAbility"), undefined, DropDownState.EXCLUDE), new DropDownLabel(i18next.t("filterBar:noHiddenAbility"), undefined, DropDownState.EXCLUDE),
]; ];
const seenSpeciesLabels = [
new DropDownLabel(i18next.t("filterBar:seenSpecies"), undefined, DropDownState.OFF),
new DropDownLabel(i18next.t("filterBar:isSeen"), undefined, DropDownState.ON),
new DropDownLabel(i18next.t("filterBar:isUnseen"), undefined, DropDownState.EXCLUDE),
];
const eggLabels = [ const eggLabels = [
new DropDownLabel(i18next.t("filterBar:egg"), undefined, DropDownState.OFF), new DropDownLabel(i18next.t("filterBar:egg"), undefined, DropDownState.OFF),
new DropDownLabel(i18next.t("filterBar:eggPurchasable"), undefined, DropDownState.ON), new DropDownLabel(i18next.t("filterBar:eggPurchasable"), undefined, DropDownState.ON),
@ -428,7 +423,6 @@ export class PokedexUiHandler extends MessageUiHandler {
new DropDownOption("FAVORITE", favoriteLabels), new DropDownOption("FAVORITE", favoriteLabels),
new DropDownOption("WIN", winLabels), new DropDownOption("WIN", winLabels),
new DropDownOption("HIDDEN_ABILITY", hiddenAbilityLabels), new DropDownOption("HIDDEN_ABILITY", hiddenAbilityLabels),
new DropDownOption("SEEN_SPECIES", seenSpeciesLabels),
new DropDownOption("EGG", eggLabels), new DropDownOption("EGG", eggLabels),
new DropDownOption("POKERUS", pokerusLabels), new DropDownOption("POKERUS", pokerusLabels),
]; ];
@ -798,15 +792,13 @@ export class PokedexUiHandler extends MessageUiHandler {
this.starterSelectMessageBoxContainer.setVisible(!!text?.length); this.starterSelectMessageBoxContainer.setVisible(!!text?.length);
} }
isSeen(species: PokemonSpecies, dexEntry: DexEntry, seenFilter?: boolean): boolean { isSeen(species: PokemonSpecies, dexEntry: DexEntry): boolean {
if (dexEntry?.seenAttr) { if (dexEntry?.seenAttr) {
return true; return true;
} }
if (!seenFilter) {
const starterDexEntry = globalScene.gameData.dexData[this.getStarterSpeciesId(species.speciesId)]; const starterDexEntry = globalScene.gameData.dexData[this.getStarterSpeciesId(species.speciesId)];
return !!starterDexEntry?.caughtAttr; return !!starterDexEntry?.caughtAttr;
}
return false;
} }
/** /**
@ -1625,21 +1617,6 @@ export class PokedexUiHandler extends MessageUiHandler {
} }
}); });
// Seen Filter
const dexEntry = globalScene.gameData.dexData[species.speciesId];
const isItSeen = this.isSeen(species, dexEntry, true);
const fitsSeen = this.filterBar.getVals(DropDownColumn.MISC).some(misc => {
if (misc.val === "SEEN_SPECIES" && misc.state === DropDownState.ON) {
return isItSeen;
}
if (misc.val === "SEEN_SPECIES" && misc.state === DropDownState.EXCLUDE) {
return !isItSeen;
}
if (misc.val === "SEEN_SPECIES" && misc.state === DropDownState.OFF) {
return true;
}
});
// Egg Purchasable Filter // Egg Purchasable Filter
const isEggPurchasable = this.isSameSpeciesEggAvailable(species.speciesId); const isEggPurchasable = this.isSameSpeciesEggAvailable(species.speciesId);
const fitsEgg = this.filterBar.getVals(DropDownColumn.MISC).some(misc => { const fitsEgg = this.filterBar.getVals(DropDownColumn.MISC).some(misc => {
@ -1681,7 +1658,6 @@ export class PokedexUiHandler extends MessageUiHandler {
fitsFavorite && fitsFavorite &&
fitsWin && fitsWin &&
fitsHA && fitsHA &&
fitsSeen &&
fitsEgg && fitsEgg &&
fitsPokerus fitsPokerus
) { ) {

View File

@ -208,10 +208,9 @@ export class RunInfoUiHandler extends UiHandler {
headerText.setOrigin(0, 0); headerText.setOrigin(0, 0);
headerText.setPositionRelative(headerBg, 8, 4); headerText.setPositionRelative(headerBg, 8, 4);
this.runContainer.add(headerText); this.runContainer.add(headerText);
const runName = addTextObject(0, 0, this.runInfo.name, TextStyle.WINDOW); const runName = addTextObject(0, 0, this.runInfo.runNameText, TextStyle.WINDOW);
runName.setOrigin(0, 0); runName.setOrigin(0, 0);
const runNameX = headerText.width / 6 + headerText.x + 4; runName.setPositionRelative(headerBg, 60, 4);
runName.setPositionRelative(headerBg, runNameX, 4);
this.runContainer.add(runName); this.runContainer.add(runName);
} }

View File

@ -377,7 +377,7 @@ export class SaveSlotSelectUiHandler extends MessageUiHandler {
"select_cursor_highlight_thick", "select_cursor_highlight_thick",
undefined, undefined,
294, 294,
this.sessionSlots[prevSlotIndex ?? 0]?.saveData?.name ? 50 : 60, this.sessionSlots[prevSlotIndex ?? 0]?.saveData?.runNameText ? 50 : 60,
6, 6,
6, 6,
6, 6,
@ -553,10 +553,10 @@ class SessionSlot extends Phaser.GameObjects.Container {
} }
async setupWithData(data: SessionSaveData) { async setupWithData(data: SessionSaveData) {
const hasName = data?.name; const hasName = data?.runNameText;
this.remove(this.loadingLabel, true); this.remove(this.loadingLabel, true);
if (hasName) { if (hasName) {
const nameLabel = addTextObject(8, 5, data.name, TextStyle.WINDOW); const nameLabel = addTextObject(8, 5, data.runNameText, TextStyle.WINDOW);
this.add(nameLabel); this.add(nameLabel);
} else { } else {
const fallbackName = this.decideFallback(data); const fallbackName = this.decideFallback(data);

View File

@ -45,17 +45,17 @@ export function deepMergeSpriteData(dest: object, source: object) {
} }
export function encrypt(data: string, bypassLogin: boolean): string { export function encrypt(data: string, bypassLogin: boolean): string {
if (bypassLogin) { return (bypassLogin
return btoa(encodeURIComponent(data)); ? (data: string) => btoa(encodeURIComponent(data))
} : (data: string) => AES.encrypt(data, saveKey))(data) as unknown as string; // TODO: is this correct?
return AES.encrypt(data, saveKey).toString();
} }
export function decrypt(data: string, bypassLogin: boolean): string { export function decrypt(data: string, bypassLogin: boolean): string {
if (bypassLogin) { return (
return decodeURIComponent(atob(data)); bypassLogin
} ? (data: string) => decodeURIComponent(atob(data))
return AES.decrypt(data, saveKey).toString(enc.Utf8); : (data: string) => AES.decrypt(data, saveKey).toString(enc.Utf8)
)(data);
} }
// the latest data saved/loaded for the Starter Preferences. Required to reduce read/writes. Initialize as "{}", since this is the default value and no data needs to be stored if present. // the latest data saved/loaded for the Starter Preferences. Required to reduce read/writes. Initialize as "{}", since this is the default value and no data needs to be stored if present.

View File

@ -2,24 +2,21 @@ import "vitest";
import type { TerrainType } from "#app/data/terrain"; import type { TerrainType } from "#app/data/terrain";
import type Overrides from "#app/overrides"; import type Overrides from "#app/overrides";
import type { ArenaTag } from "#data/arena-tag"; import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag";
import type { PositionalTag } from "#data/positional-tags/positional-tag";
import type { AbilityId } from "#enums/ability-id"; import type { AbilityId } from "#enums/ability-id";
import type { ArenaTagSide } from "#enums/arena-tag-side"; import type { ArenaTagSide } from "#enums/arena-tag-side";
import type { ArenaTagType } from "#enums/arena-tag-type"; import type { ArenaTagType } from "#enums/arena-tag-type";
import type { BattlerTagType } from "#enums/battler-tag-type"; import type { BattlerTagType } from "#enums/battler-tag-type";
import type { MoveId } from "#enums/move-id"; import type { MoveId } from "#enums/move-id";
import type { PokemonType } from "#enums/pokemon-type"; import type { PokemonType } from "#enums/pokemon-type";
import type { PositionalTagType } from "#enums/positional-tag-type";
import type { BattleStat, EffectiveStat, Stat } from "#enums/stat"; import type { BattleStat, EffectiveStat, Stat } from "#enums/stat";
import type { StatusEffect } from "#enums/status-effect"; import type { StatusEffect } from "#enums/status-effect";
import type { WeatherType } from "#enums/weather-type"; import type { WeatherType } from "#enums/weather-type";
import type { Arena } from "#field/arena"; import type { Arena } from "#field/arena";
import type { Pokemon } from "#field/pokemon"; import type { Pokemon } from "#field/pokemon";
import type { PokemonMove } from "#moves/pokemon-move"; import type { PokemonMove } from "#moves/pokemon-move";
import type { toHaveArenaTagOptions } from "#test/test-utils/matchers/to-have-arena-tag"; import type { OneOther } from "#test/@types/test-helpers";
import type { toHaveEffectiveStatOptions } from "#test/test-utils/matchers/to-have-effective-stat"; import type { ToHaveEffectiveStatMatcherOptions } from "#test/test-utils/matchers/to-have-effective-stat";
import type { toHavePositionalTagOptions } from "#test/test-utils/matchers/to-have-positional-tag";
import type { expectedStatusType } from "#test/test-utils/matchers/to-have-status-effect"; 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 { toHaveTypesOptions } from "#test/test-utils/matchers/to-have-types";
import type { TurnMove } from "#types/turn-move"; import type { TurnMove } from "#types/turn-move";
@ -28,7 +25,7 @@ import type { toDmgValue } from "utils/common";
import type { expect } from "vitest"; import type { expect } from "vitest";
declare module "vitest" { declare module "vitest" {
interface Assertion<T> { interface Assertion {
/** /**
* Check whether an array contains EXACTLY the given items (in any order). * Check whether an array contains EXACTLY the given items (in any order).
* *
@ -38,9 +35,46 @@ declare module "vitest" {
* @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} * @see {@linkcode expect.arrayContaining}
*/ */
toEqualArrayUnsorted(expected: T[]): void; toEqualArrayUnsorted<E>(expected: E[]): void;
// #region Arena Matchers /**
* 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
*/
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. * Check whether the current {@linkcode WeatherType} is as expected.
@ -56,59 +90,26 @@ declare module "vitest" {
/** /**
* Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}. * Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}.
* @param expectedTag - A partially-filled {@linkcode ArenaTag} containing the desired properties *
* @param expectedType - A partially-filled {@linkcode ArenaTag} containing the desired properties
*/ */
toHaveArenaTag<A extends ArenaTagType>(expectedTag: toHaveArenaTagOptions<A>): void; toHaveArenaTag<T extends ArenaTagType>(
expectedType: OneOther<ArenaTagTypeMap[T], "tagType" | "side"> & { tagType: T }, // intersection required bc this doesn't preserve T
): void;
/** /**
* Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}. * Check whether the current {@linkcode Arena} contains the given {@linkcode ArenaTag}.
*
* @param expectedType - The {@linkcode ArenaTagType} of the desired tag * @param expectedType - The {@linkcode ArenaTagType} of the desired tag
* @param side - The {@linkcode ArenaTagSide | side(s) of the field} the tag should affect; default {@linkcode ArenaTagSide.BOTH} * @param side - The {@linkcode ArenaTagSide | side of the field} the tag should affect, or
* {@linkcode ArenaTagSide.BOTH} to check both sides;
* default `ArenaTagSide.BOTH`
*/ */
toHaveArenaTag(expectedType: ArenaTagType, side?: ArenaTagSide): void; toHaveArenaTag(expectedType: ArenaTagType, side?: ArenaTagSide): void;
/** /**
* Check whether the current {@linkcode Arena} contains the given {@linkcode PositionalTag}. * Check whether a {@linkcode Pokemon} is at full HP.
* @param expectedTag - A partially-filled `PositionalTag` containing the desired properties
*/ */
toHavePositionalTag<P extends PositionalTagType>(expectedTag: toHavePositionalTagOptions<P>): void; toHaveFullHp(): void;
/**
* Check whether the current {@linkcode Arena} contains the given number of {@linkcode PositionalTag}s.
* @param expectedType - The {@linkcode PositionalTagType} of the desired tag
* @param count - The number of instances of {@linkcode expectedType} that should be active;
* defaults to `1` and must be within the range `[0, 4]`
*/
toHavePositionalTag(expectedType: PositionalTagType, count?: number): void;
// #endregion Arena Matchers
// #region Pokemon Matchers
/**
* Check whether a {@linkcode Pokemon}'s current typing includes the given types.
* @param expectedTypes - The expected {@linkcode PokemonType}s to check against; must have length `>0`
* @param options - The {@linkcode toHaveTypesOptions | options} passed to the matcher
*/
toHaveTypes(expectedTypes: PokemonType[], options?: toHaveTypesOptions): void;
/**
* Check whether a {@linkcode Pokemon} has used a move matching the given criteria.
* @param expectedMove - 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`
* @see {@linkcode Pokemon.getLastXMoves}
*/
toHaveUsedMove(expectedMove: 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 - The {@linkcode toHaveEffectiveStatOptions | options} passed to the matcher
* @remarks
* If you want to check the stat **before** modifiers are applied, use {@linkcode Pokemon.getStat} instead.
*/
toHaveEffectiveStat(stat: EffectiveStat, expectedValue: number, options?: toHaveEffectiveStatOptions): void;
/** /**
* Check whether a {@linkcode Pokemon} has a specific {@linkcode StatusEffect | non-volatile status effect}. * Check whether a {@linkcode Pokemon} has a specific {@linkcode StatusEffect | non-volatile status effect}.
@ -132,7 +133,7 @@ declare module "vitest" {
/** /**
* Check whether a {@linkcode Pokemon} has applied a specific {@linkcode AbilityId}. * Check whether a {@linkcode Pokemon} has applied a specific {@linkcode AbilityId}.
* @param expectedAbilityId - The `AbilityId` to check for * @param expectedAbilityId - The expected {@linkcode AbilityId}
*/ */
toHaveAbilityApplied(expectedAbilityId: AbilityId): void; toHaveAbilityApplied(expectedAbilityId: AbilityId): void;
@ -142,36 +143,24 @@ declare module "vitest" {
*/ */
toHaveHp(expectedHp: number): void; toHaveHp(expectedHp: number): 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 `expectedDamageTaken` with {@linkcode toDmgValue}; default `true`
*/
toHaveTakenDamage(expectedDamageTaken: number, roundDown?: boolean): void;
/** /**
* Check whether a {@linkcode Pokemon} is currently fainted (as determined by {@linkcode Pokemon.isFainted}). * Check whether a {@linkcode Pokemon} is currently fainted (as determined by {@linkcode Pokemon.isFainted}).
* @remarks * @remarks
* When checking whether an enemy wild Pokemon is fainted, one must store a reference to it in a variable _before_ the fainting effect occurs. * When checking whether an enemy wild Pokemon is fainted, one must reference it in a variable _before_ the fainting effect occurs
* Otherwise, the Pokemon will be removed from the field and garbage collected. * as otherwise the Pokemon will be GC'ed and rendered `undefined`.
*/ */
toHaveFainted(): void; toHaveFainted(): void;
/**
* Check whether a {@linkcode Pokemon} is at full HP.
*/
toHaveFullHp(): void;
/** /**
* Check whether a {@linkcode Pokemon} has consumed the given amount of PP for one of its moves. * Check whether a {@linkcode Pokemon} has consumed the given amount of PP for one of its moves.
* @param moveId - The {@linkcode MoveId} corresponding to the {@linkcode PokemonMove} that should have consumed PP * @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, * @param ppUsed - The numerical amount of PP that should have been consumed,
* or `all` to indicate the move should be _out_ of PP * or `all` to indicate the move should be _out_ of PP
* @remarks * @remarks
* If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.ENEMY_MOVESET_OVERRIDE} * If the Pokemon's moveset has been set via {@linkcode Overrides.MOVESET_OVERRIDE}/{@linkcode Overrides.ENEMY_MOVESET_OVERRIDE},
* or does not contain exactly one copy of `moveId`, this will fail the test. * does not contain {@linkcode expectedMove}
* or contains the desired move more than once, this will fail the test.
*/ */
toHaveUsedPP(moveId: MoveId, ppUsed: number | "all"): void; toHaveUsedPP(expectedMove: MoveId, ppUsed: number | "all"): void;
// #endregion Pokemon Matchers
} }
} }

View File

@ -1,10 +1,8 @@
import { AbilityId } from "#enums/ability-id"; import { AbilityId } from "#enums/ability-id";
import { Challenges } from "#enums/challenges"; import { Challenges } from "#enums/challenges";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { MysteryEncounterType } from "#enums/mystery-encounter-type";
import { PokeballType } from "#enums/pokeball"; import { PokeballType } from "#enums/pokeball";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { runMysteryEncounterToEnd } from "#test/mystery-encounter/encounter-test-utils";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import Phaser from "phaser"; import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
@ -54,18 +52,4 @@ describe("Challenges - Limited Catch", () => {
expect(game.scene.getPlayerParty()).toHaveLength(1); expect(game.scene.getPlayerParty()).toHaveLength(1);
}); });
it("should allow gift Pokémon from Mystery Encounters to be added to party", async () => {
game.override
.mysteryEncounterChance(100)
.mysteryEncounter(MysteryEncounterType.THE_POKEMON_SALESMAN)
.startingWave(12);
game.scene.money = 20000;
await game.challengeMode.runToSummon([SpeciesId.NUZLEAF]);
await runMysteryEncounterToEnd(game, 1);
expect(game.scene.getPlayerParty()).toHaveLength(2);
});
}); });

View File

@ -3,7 +3,6 @@ import { Challenges } from "#enums/challenges";
import { MoveId } from "#enums/move-id"; import { MoveId } from "#enums/move-id";
import { SpeciesId } from "#enums/species-id"; import { SpeciesId } from "#enums/species-id";
import { UiMode } from "#enums/ui-mode"; import { UiMode } from "#enums/ui-mode";
import { ExpBoosterModifier } from "#modifiers/modifier";
import { GameManager } from "#test/test-utils/game-manager"; import { GameManager } from "#test/test-utils/game-manager";
import { ModifierSelectUiHandler } from "#ui/modifier-select-ui-handler"; import { ModifierSelectUiHandler } from "#ui/modifier-select-ui-handler";
import Phaser from "phaser"; import Phaser from "phaser";
@ -76,7 +75,6 @@ describe("Challenges - Limited Support", () => {
await game.doKillOpponents(); await game.doKillOpponents();
await game.toNextWave(); await game.toNextWave();
expect(game.scene.getModifiers(ExpBoosterModifier)).toHaveLength(1);
expect(playerPokemon).not.toHaveFullHp(); expect(playerPokemon).not.toHaveFullHp();
game.move.use(MoveId.SPLASH); game.move.use(MoveId.SPLASH);

View File

@ -6,7 +6,6 @@ import { toHaveEffectiveStat } from "#test/test-utils/matchers/to-have-effective
import { toHaveFainted } from "#test/test-utils/matchers/to-have-fainted"; import { toHaveFainted } from "#test/test-utils/matchers/to-have-fainted";
import { toHaveFullHp } from "#test/test-utils/matchers/to-have-full-hp"; import { toHaveFullHp } from "#test/test-utils/matchers/to-have-full-hp";
import { toHaveHp } from "#test/test-utils/matchers/to-have-hp"; import { toHaveHp } from "#test/test-utils/matchers/to-have-hp";
import { toHavePositionalTag } from "#test/test-utils/matchers/to-have-positional-tag";
import { toHaveStatStage } from "#test/test-utils/matchers/to-have-stat-stage"; import { toHaveStatStage } from "#test/test-utils/matchers/to-have-stat-stage";
import { toHaveStatusEffect } from "#test/test-utils/matchers/to-have-status-effect"; import { toHaveStatusEffect } from "#test/test-utils/matchers/to-have-status-effect";
import { toHaveTakenDamage } from "#test/test-utils/matchers/to-have-taken-damage"; import { toHaveTakenDamage } from "#test/test-utils/matchers/to-have-taken-damage";
@ -24,20 +23,19 @@ import { expect } from "vitest";
expect.extend({ expect.extend({
toEqualArrayUnsorted, toEqualArrayUnsorted,
toHaveWeather,
toHaveTerrain,
toHaveArenaTag,
toHavePositionalTag,
toHaveTypes, toHaveTypes,
toHaveUsedMove, toHaveUsedMove,
toHaveEffectiveStat, toHaveEffectiveStat,
toHaveTakenDamage,
toHaveWeather,
toHaveTerrain,
toHaveArenaTag,
toHaveFullHp,
toHaveStatusEffect, toHaveStatusEffect,
toHaveStatStage, toHaveStatStage,
toHaveBattlerTag, toHaveBattlerTag,
toHaveAbilityApplied, toHaveAbilityApplied,
toHaveHp, toHaveHp,
toHaveTakenDamage,
toHaveFullHp,
toHaveFainted, toHaveFainted,
toHaveUsedPP, toHaveUsedPP,
}); });

View File

@ -39,6 +39,15 @@ describe("Move - Wish", () => {
.enemyLevel(100); .enemyLevel(100);
}); });
/**
* Expect that wish is active with the specified number of attacks.
* @param numAttacks - The number of wish instances that should be queued; default `1`
*/
function expectWishActive(numAttacks = 1) {
const wishes = game.scene.arena.positionalTagManager["tags"].filter(t => t.tagType === PositionalTagType.WISH);
expect(wishes).toHaveLength(numAttacks);
}
it("should heal the Pokemon in the current slot for 50% of the user's maximum HP", async () => { it("should heal the Pokemon in the current slot for 50% of the user's maximum HP", async () => {
await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]); await game.classicMode.startBattle([SpeciesId.ALOMOMOLA, SpeciesId.BLISSEY]);
@ -49,19 +58,19 @@ describe("Move - Wish", () => {
game.move.use(MoveId.WISH); game.move.use(MoveId.WISH);
await game.toNextTurn(); await game.toNextTurn();
expect(game).toHavePositionalTag(PositionalTagType.WISH); expectWishActive();
game.doSwitchPokemon(1); game.doSwitchPokemon(1);
await game.toEndOfTurn(); await game.toEndOfTurn();
expect(game).toHavePositionalTag(PositionalTagType.WISH, 0); expectWishActive(0);
expect(game.textInterceptor.logs).toContain( expect(game.textInterceptor.logs).toContain(
i18next.t("arenaTag:wishTagOnAdd", { i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(alomomola), pokemonNameWithAffix: getPokemonNameWithAffix(alomomola),
}), }),
); );
expect(alomomola).toHaveHp(1); expect(alomomola.hp).toBe(1);
expect(blissey).toHaveHp(toDmgValue(alomomola.getMaxHp() / 2) + 1); expect(blissey.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1);
}); });
it("should work if the user has full HP, but not if it already has an active Wish", async () => { it("should work if the user has full HP, but not if it already has an active Wish", async () => {
@ -73,13 +82,13 @@ describe("Move - Wish", () => {
game.move.use(MoveId.WISH); game.move.use(MoveId.WISH);
await game.toNextTurn(); await game.toNextTurn();
expect(game).toHavePositionalTag(PositionalTagType.WISH); expectWishActive();
game.move.use(MoveId.WISH); game.move.use(MoveId.WISH);
await game.toEndOfTurn(); await game.toEndOfTurn();
expect(alomomola.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1); expect(alomomola.hp).toBe(toDmgValue(alomomola.getMaxHp() / 2) + 1);
expect(alomomola).toHaveUsedMove({ result: MoveResult.FAIL }); expect(alomomola.getLastXMoves()[0].result).toBe(MoveResult.FAIL);
}); });
it("should function independently of Future Sight", async () => { it("should function independently of Future Sight", async () => {
@ -94,8 +103,7 @@ describe("Move - Wish", () => {
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]); await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.toNextTurn(); await game.toNextTurn();
expect(game).toHavePositionalTag(PositionalTagType.WISH); expectWishActive(1);
expect(game).toHavePositionalTag(PositionalTagType.DELAYED_ATTACK);
}); });
it("should work in double battles and trigger in order of creation", async () => { it("should work in double battles and trigger in order of creation", async () => {
@ -119,7 +127,7 @@ describe("Move - Wish", () => {
await game.setTurnOrder(oldOrder.map(p => p.getBattlerIndex())); await game.setTurnOrder(oldOrder.map(p => p.getBattlerIndex()));
await game.toNextTurn(); await game.toNextTurn();
expect(game).toHavePositionalTag(PositionalTagType.WISH, 4); expectWishActive(4);
// Lower speed to change turn order // Lower speed to change turn order
alomomola.setStatStage(Stat.SPD, 6); alomomola.setStatStage(Stat.SPD, 6);
@ -133,7 +141,7 @@ describe("Move - Wish", () => {
await game.phaseInterceptor.to("PositionalTagPhase"); await game.phaseInterceptor.to("PositionalTagPhase");
// all wishes have activated and added healing phases // all wishes have activated and added healing phases
expect(game).toHavePositionalTag(PositionalTagType.WISH, 0); expectWishActive(0);
const healPhases = game.scene.phaseManager.phaseQueue.filter(p => p.is("PokemonHealPhase")); const healPhases = game.scene.phaseManager.phaseQueue.filter(p => p.is("PokemonHealPhase"));
expect(healPhases).toHaveLength(4); expect(healPhases).toHaveLength(4);
@ -157,14 +165,14 @@ describe("Move - Wish", () => {
game.move.use(MoveId.WISH, BattlerIndex.PLAYER_2); game.move.use(MoveId.WISH, BattlerIndex.PLAYER_2);
await game.toNextTurn(); await game.toNextTurn();
expect(game).toHavePositionalTag(PositionalTagType.WISH); expectWishActive();
game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER); game.move.use(MoveId.SPLASH, BattlerIndex.PLAYER);
game.move.use(MoveId.MEMENTO, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2); game.move.use(MoveId.MEMENTO, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY_2);
await game.toEndOfTurn(); await game.toEndOfTurn();
// Wish went away without doing anything // Wish went away without doing anything
expect(game).toHavePositionalTag(PositionalTagType.WISH, 0); expectWishActive(0);
expect(game.textInterceptor.logs).not.toContain( expect(game.textInterceptor.logs).not.toContain(
i18next.t("arenaTag:wishTagOnAdd", { i18next.t("arenaTag:wishTagOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(blissey), pokemonNameWithAffix: getPokemonNameWithAffix(blissey),

View File

@ -1,5 +1,4 @@
import { getOnelineDiffStr } from "#test/test-utils/string-utils"; import { getOnelineDiffStr } from "#test/test-utils/string-utils";
import { receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/** /**
@ -15,22 +14,22 @@ export function toEqualArrayUnsorted(
): SyncExpectationResult { ): SyncExpectationResult {
if (!Array.isArray(received)) { if (!Array.isArray(received)) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected to receive an array, but got ${receivedStr(received)}!`, message: () => `Expected an array, but got ${this.utils.stringify(received)}!`,
}; };
} }
if (received.length !== expected.length) { if (received.length !== expected.length) {
return { return {
pass: false, pass: false,
message: () => `Expected to receive an array of length ${received.length}, but got ${expected.length} instead!`, message: () => `Expected to receive array of length ${received.length}, but got ${expected.length} instead!`,
expected,
actual: received, actual: received,
expected,
}; };
} }
const actualSorted = received.toSorted(); const actualSorted = received.slice().sort();
const expectedSorted = expected.toSorted(); const expectedSorted = expected.slice().sort();
const pass = this.equals(actualSorted, expectedSorted, [...this.customTesters, this.utils.iterableEquality]); const pass = this.equals(actualSorted, expectedSorted, [...this.customTesters, this.utils.iterableEquality]);
const actualStr = getOnelineDiffStr.call(this, actualSorted); const actualStr = getOnelineDiffStr.call(this, actualSorted);

View File

@ -21,8 +21,8 @@ export function toHaveAbilityApplied(
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected to receive a Pokemon, but got ${receivedStr(received)}!`, message: () => `Expected to recieve a Pokemon, but got ${receivedStr(received)}!`,
}; };
} }

View File

@ -1,22 +1,20 @@
import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag"; import type { ArenaTag, ArenaTagTypeMap } from "#data/arena-tag";
import type { ArenaTagSide } from "#enums/arena-tag-side"; import type { ArenaTagSide } from "#enums/arena-tag-side";
import type { ArenaTagType } from "#enums/arena-tag-type"; import { ArenaTagType } from "#enums/arena-tag-type";
import type { OneOther } from "#test/@types/test-helpers"; import type { OneOther } from "#test/@types/test-helpers";
// biome-ignore lint/correctness/noUnusedImports: TSDoc // biome-ignore lint/correctness/noUnusedImports: TSDoc
import type { GameManager } from "#test/test-utils/game-manager"; import type { GameManager } from "#test/test-utils/game-manager";
import { getOnelineDiffStr } from "#test/test-utils/string-utils"; import { getEnumStr, getOnelineDiffStr, stringifyEnumArray } from "#test/test-utils/string-utils";
import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils"; import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils";
import type { NonFunctionPropertiesRecursive } from "#types/type-helpers";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
// intersection required to preserve T for inferences export type toHaveArenaTagOptions<T extends ArenaTagType> = OneOther<ArenaTagTypeMap[T], "tagType">;
export type toHaveArenaTagOptions<T extends ArenaTagType> = OneOther<ArenaTagTypeMap[T], "tagType" | "side"> & {
tagType: T;
};
/** /**
* Matcher to check if the {@linkcode Arena} has a given {@linkcode ArenaTag} active. * Matcher to check if the {@linkcode Arena} has a given {@linkcode ArenaTag} active.
* @param received - The object to check. Should be the current {@linkcode GameManager}. * @param received - The object to check. Should be the current {@linkcode GameManager}.
* @param expectedTag - The `ArenaTagType` of the desired tag, or a partially-filled object * @param expectedType - The {@linkcode ArenaTagType} of the desired tag, or a partially-filled object
* containing the desired properties * containing the desired properties
* @param side - The {@linkcode ArenaTagSide | side of the field} the tag should affect, or * @param side - The {@linkcode ArenaTagSide | side of the field} the tag should affect, or
* {@linkcode ArenaTagSide.BOTH} to check both sides * {@linkcode ArenaTagSide.BOTH} to check both sides
@ -25,53 +23,58 @@ export type toHaveArenaTagOptions<T extends ArenaTagType> = OneOther<ArenaTagTyp
export function toHaveArenaTag<T extends ArenaTagType>( export function toHaveArenaTag<T extends ArenaTagType>(
this: MatcherState, this: MatcherState,
received: unknown, received: unknown,
expectedTag: T | toHaveArenaTagOptions<T>, // simplified types used for brevity; full overloads are in `vitest.d.ts`
expectedType: T | (Partial<NonFunctionPropertiesRecursive<ArenaTag>> & { tagType: T; side: ArenaTagSide }),
side?: ArenaTagSide, side?: ArenaTagSide,
): SyncExpectationResult { ): SyncExpectationResult {
if (!isGameManagerInstance(received)) { if (!isGameManagerInstance(received)) {
return { return {
pass: this.isNot, pass: this.isNot,
message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`, message: () => `Expected to recieve a GameManager, but got ${receivedStr(received)}!`,
}; };
} }
if (!received.scene?.arena) { if (!received.scene?.arena) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected GameManager.${received.scene ? "scene.arena" : "scene"} to be defined!`, message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`,
}; };
} }
// Coerce lone `tagType`s into objects if (typeof expectedType === "string") {
// Bangs are ok as we enforce safety via overloads // Coerce lone `tagType`s into objects
// @ts-expect-error - Typescript is being stupid as tag type and side will always exist // Bangs are ok as we enforce safety via overloads
const etag: Partial<ArenaTag> & { tagType: T; side: ArenaTagSide } = expectedType = { tagType: expectedType, side: side! };
typeof expectedTag === "object" ? expectedTag : { tagType: expectedTag, side: side! }; }
// We need to get all tags for the case of checking properties of a tag present on both sides of the arena // We need to get all tags for the case of checking properties of a tag present on both sides of the arena
const tags = received.scene.arena.findTagsOnSide(t => t.tagType === etag.tagType, etag.side); const tags = received.scene.arena.findTagsOnSide(t => t.tagType === expectedType.tagType, expectedType.side);
if (tags.length === 0) { if (!tags.length) {
const expectedStr = getEnumStr(ArenaTagType, expectedType.tagType);
return { return {
pass: false, pass: false,
message: () => `Expected the Arena to have a tag of type ${etag.tagType}, but it didn't!`, message: () => `Expected the arena to have a tag matching ${expectedStr}, but it didn't!`,
expected: etag.tagType, expected: getEnumStr(ArenaTagType, expectedType.tagType),
actual: received.scene.arena.tags.map(t => t.tagType), actual: stringifyEnumArray(
ArenaTagType,
received.scene.arena.tags.map(t => t.tagType),
),
}; };
} }
// Pass if any of the matching tags meet our criteria // Pass if any of the matching tags meet our criteria
const pass = tags.some(tag => const pass = tags.some(tag =>
this.equals(tag, expectedTag, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]), this.equals(tag, expectedType, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]),
); );
const expectedStr = getOnelineDiffStr.call(this, expectedTag); const expectedStr = getOnelineDiffStr.call(this, expectedType);
return { return {
pass, pass,
message: () => message: () =>
pass pass
? `Expected the Arena to NOT have a tag matching ${expectedStr}, but it did!` ? `Expected the arena to NOT have a tag matching ${expectedStr}, but it did!`
: `Expected the Arena to have a tag matching ${expectedStr}, but it didn't!`, : `Expected the arena to have a tag matching ${expectedStr}, but it didn't!`,
expected: expectedTag, expected: expectedType,
actual: tags, actual: tags,
}; };
} }

View File

@ -6,7 +6,7 @@ import { getStatName } from "#test/test-utils/string-utils";
import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils"; import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export interface toHaveEffectiveStatOptions { export interface ToHaveEffectiveStatMatcherOptions {
/** /**
* The target {@linkcode Pokemon} * The target {@linkcode Pokemon}
* @see {@linkcode Pokemon.getEffectiveStat} * @see {@linkcode Pokemon.getEffectiveStat}
@ -30,7 +30,7 @@ export interface toHaveEffectiveStatOptions {
* @param received - The object to check. Should be a {@linkcode Pokemon} * @param received - The object to check. Should be a {@linkcode Pokemon}
* @param stat - The {@linkcode EffectiveStat} to check * @param stat - The {@linkcode EffectiveStat} to check
* @param expectedValue - The expected value of the {@linkcode stat} * @param expectedValue - The expected value of the {@linkcode stat}
* @param options - The {@linkcode toHaveEffectiveStatOptions} * @param options - The {@linkcode ToHaveEffectiveStatMatcherOptions}
* @returns Whether the matcher passed * @returns Whether the matcher passed
*/ */
export function toHaveEffectiveStat( export function toHaveEffectiveStat(
@ -38,11 +38,11 @@ export function toHaveEffectiveStat(
received: unknown, received: unknown,
stat: EffectiveStat, stat: EffectiveStat,
expectedValue: number, expectedValue: number,
{ enemy, move, isCritical = false }: toHaveEffectiveStatOptions = {}, { enemy, move, isCritical = false }: ToHaveEffectiveStatMatcherOptions = {},
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }

View File

@ -12,7 +12,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export function toHaveFainted(this: MatcherState, received: unknown): SyncExpectationResult { export function toHaveFainted(this: MatcherState, received: unknown): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }

View File

@ -12,7 +12,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export function toHaveFullHp(this: MatcherState, received: unknown): SyncExpectationResult { export function toHaveFullHp(this: MatcherState, received: unknown): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }

View File

@ -13,7 +13,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export function toHaveHp(this: MatcherState, received: unknown, expectedHp: number): SyncExpectationResult { export function toHaveHp(this: MatcherState, received: unknown, expectedHp: number): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }

View File

@ -1,107 +0,0 @@
// biome-ignore-start lint/correctness/noUnusedImports: TSDoc
import type { GameManager } from "#test/test-utils/game-manager";
// biome-ignore-end lint/correctness/noUnusedImports: TSDoc
import type { serializedPosTagMap } from "#data/positional-tags/load-positional-tag";
import type { PositionalTagType } from "#enums/positional-tag-type";
import type { OneOther } from "#test/@types/test-helpers";
import { getOnelineDiffStr } from "#test/test-utils/string-utils";
import { isGameManagerInstance, receivedStr } from "#test/test-utils/test-utils";
import { toTitleCase } from "#utils/strings";
import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export type toHavePositionalTagOptions<P extends PositionalTagType> = OneOther<serializedPosTagMap[P], "tagType"> & {
tagType: P;
};
/**
* Matcher to check if the {@linkcode Arena} has a certain number of {@linkcode PositionalTag}s active.
* @param received - The object to check. Should be the current {@linkcode GameManager}
* @param expectedTag - The {@linkcode PositionalTagType} of the desired tag, or a partially-filled {@linkcode PositionalTag}
* containing the desired properties
* @param count - The number of tags that should be active; defaults to `1` and must be within the range `[0, 4]`
* @returns The result of the matching
*/
export function toHavePositionalTag<P extends PositionalTagType>(
this: MatcherState,
received: unknown,
expectedTag: P | toHavePositionalTagOptions<P>,
count = 1,
): SyncExpectationResult {
if (!isGameManagerInstance(received)) {
return {
pass: this.isNot,
message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`,
};
}
if (!received.scene?.arena?.positionalTagManager) {
return {
pass: this.isNot,
message: () =>
`Expected GameManager.${received.scene?.arena ? "scene.arena.positionalTagManager" : received.scene ? "scene.arena" : "scene"} to be defined!`,
};
}
// TODO: Increase limit if triple battles are added
if (count < 0 || count > 4) {
return {
pass: this.isNot,
message: () => `Expected count to be between 0 and 4, but got ${count} instead!`,
};
}
const allTags = received.scene.arena.positionalTagManager.tags;
const tagType = typeof expectedTag === "string" ? expectedTag : expectedTag.tagType;
const matchingTags = allTags.filter(t => t.tagType === tagType);
// If checking exclusively tag type, check solely the number of matching tags on field
if (typeof expectedTag === "string") {
const pass = matchingTags.length === count;
const expectedStr = getPosTagStr(expectedTag);
return {
pass,
message: () =>
pass
? `Expected the Arena to NOT have ${count} ${expectedStr} active, but it did!`
: `Expected the Arena to have ${count} ${expectedStr} active, but got ${matchingTags.length} instead!`,
expected: expectedTag,
actual: allTags,
};
}
// Check for equality with the provided object
if (matchingTags.length === 0) {
return {
pass: false,
message: () => `Expected the Arena to have a tag of type ${expectedTag.tagType}, but it didn't!`,
expected: expectedTag.tagType,
actual: received.scene.arena.tags.map(t => t.tagType),
};
}
// Pass if any of the matching tags meet the criteria
const pass = matchingTags.some(tag =>
this.equals(tag, expectedTag, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]),
);
const expectedStr = getOnelineDiffStr.call(this, expectedTag);
return {
pass,
message: () =>
pass
? `Expected the Arena to NOT have a tag matching ${expectedStr}, but it did!`
: `Expected the Arena to have a tag matching ${expectedStr}, but it didn't!`,
expected: expectedTag,
actual: matchingTags,
};
}
function getPosTagStr(pType: PositionalTagType, count = 1): string {
let ret = toTitleCase(pType) + "Tag";
if (count > 1) {
ret += "s";
}
return ret;
}

View File

@ -23,14 +23,14 @@ export function toHaveStatStage(
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }
if (expectedStage < -6 || expectedStage > 6) { if (expectedStage < -6 || expectedStage > 6) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected ${expectedStage} to be within the range [-6, 6]!`, message: () => `Expected ${expectedStage} to be within the range [-6, 6]!`,
}; };
} }

View File

@ -28,7 +28,7 @@ export function toHaveStatusEffect(
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }

View File

@ -24,7 +24,7 @@ export function toHaveTakenDamage(
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }

View File

@ -20,15 +20,15 @@ export function toHaveTerrain(
): SyncExpectationResult { ): SyncExpectationResult {
if (!isGameManagerInstance(received)) { if (!isGameManagerInstance(received)) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`, message: () => `Expected GameManager, but got ${receivedStr(received)}!`,
}; };
} }
if (!received.scene?.arena) { if (!received.scene?.arena) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected GameManager.${received.scene ? "scene.arena" : "scene"} to be defined!`, message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`,
}; };
} }
@ -41,8 +41,8 @@ export function toHaveTerrain(
pass, pass,
message: () => message: () =>
pass pass
? `Expected the Arena to NOT have ${expectedStr} active, but it did!` ? `Expected Arena to NOT have ${expectedStr} active, but it did!`
: `Expected the Arena to have ${expectedStr} active, but got ${actualStr} instead!`, : `Expected Arena to have ${expectedStr} active, but got ${actualStr} instead!`,
expected: expectedTerrainType, expected: expectedTerrainType,
actual, actual,
}; };

View File

@ -7,16 +7,10 @@ import { isPokemonInstance, receivedStr } from "../test-utils";
export interface toHaveTypesOptions { export interface toHaveTypesOptions {
/** /**
* Value dictating the strength of the enforced typing match. * Whether to enforce exact matches (`true`) or superset matches (`false`).
* * @defaultValue `true`
* Possible values (in ascending order of strength) are:
* - `"ordered"`: Enforce that the {@linkcode Pokemon}'s types are identical **and in the same order**
* - `"unordered"`: Enforce that the {@linkcode Pokemon}'s types are identical **without checking order**
* - `"superset"`: Enforce that the {@linkcode Pokemon}'s types are **a superset of** the expected types
* (all must be present, but extras can be there)
* @defaultValue `"unordered"`
*/ */
mode?: "ordered" | "unordered" | "superset"; exact?: boolean;
/** /**
* Optional arguments to pass to {@linkcode Pokemon.getTypes}. * Optional arguments to pass to {@linkcode Pokemon.getTypes}.
*/ */
@ -24,54 +18,35 @@ export interface toHaveTypesOptions {
} }
/** /**
* Matcher that checks if a Pokemon's typing is as expected. * Matcher that checks if an array contains exactly the given items, disregarding order.
* @param received - The object to check. Should be a {@linkcode Pokemon} * @param received - The object to check. Should be an array of one or more {@linkcode PokemonType}s.
* @param expectedTypes - An array of one or more {@linkcode PokemonType}s to compare against. * @param options - The {@linkcode toHaveTypesOptions | options} for this matcher
* @param mode - The mode to perform the matching in.
* Possible values (in ascending order of strength) are:
* - `"ordered"`: Enforce that the {@linkcode Pokemon}'s types are identical **and in the same order**
* - `"unordered"`: Enforce that the {@linkcode Pokemon}'s types are identical **without checking order**
* - `"superset"`: Enforce that the {@linkcode Pokemon}'s types are **a superset of** the expected types
* (all must be present, but extras can be there)
*
* Default `unordered`
* @param args - Extra arguments passed to {@linkcode Pokemon.getTypes}
* @returns The result of the matching * @returns The result of the matching
*/ */
export function toHaveTypes( export function toHaveTypes(
this: MatcherState, this: MatcherState,
received: unknown, received: unknown,
expectedTypes: [PokemonType, ...PokemonType[]], expected: [PokemonType, ...PokemonType[]],
{ mode = "unordered", args = [] }: toHaveTypesOptions = {}, options: toHaveTypesOptions = {},
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to recieve a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }
// Return early if no types were passed in const actualTypes = received.getTypes(...(options.args ?? [])).sort();
if (expectedTypes.length === 0) { const expectedTypes = expected.slice().sort();
return {
pass: this.isNot,
message: () => "Expected to receive a non-empty array of PokemonTypes!",
};
}
// Avoid sorting the types if strict ordering is desired
const actualSorted = mode === "ordered" ? received.getTypes(...args) : received.getTypes(...args).toSorted();
const expectedSorted = mode === "ordered" ? expectedTypes : expectedTypes.toSorted();
// Exact matches do not care about subset equality // Exact matches do not care about subset equality
const matchers = const matchers = options.exact
mode === "superset" ? [...this.customTesters, this.utils.iterableEquality]
? [...this.customTesters, this.utils.iterableEquality] : [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality];
: [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]; const pass = this.equals(actualTypes, expectedTypes, matchers);
const pass = this.equals(actualSorted, expectedSorted, matchers);
const actualStr = stringifyEnumArray(PokemonType, actualSorted); const actualStr = stringifyEnumArray(PokemonType, actualTypes);
const expectedStr = stringifyEnumArray(PokemonType, expectedSorted); const expectedStr = stringifyEnumArray(PokemonType, expectedTypes);
const pkmName = getPokemonNameWithAffix(received); const pkmName = getPokemonNameWithAffix(received);
return { return {
@ -80,7 +55,7 @@ export function toHaveTypes(
pass pass
? `Expected ${pkmName} to NOT have types ${expectedStr}, but it did!` ? `Expected ${pkmName} to NOT have types ${expectedStr}, but it did!`
: `Expected ${pkmName} to have types ${expectedStr}, but got ${actualStr} instead!`, : `Expected ${pkmName} to have types ${expectedStr}, but got ${actualStr} instead!`,
expected: expectedSorted, expected: expectedTypes,
actual: actualSorted, actual: actualTypes,
}; };
} }

View File

@ -13,7 +13,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/** /**
* Matcher to check the contents of a {@linkcode Pokemon}'s move history. * Matcher to check the contents of a {@linkcode Pokemon}'s move history.
* @param received - The actual value received. Should be a {@linkcode Pokemon} * @param received - The actual value received. Should be a {@linkcode Pokemon}
* @param expectedMove - The {@linkcode MoveId} the Pokemon is expected to have used, * @param expectedValue - The {@linkcode MoveId} the Pokemon is expected to have used,
* or a partially filled {@linkcode TurnMove} containing the desired properties to check * 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. * @param index - The index of the move history entry to check, in order from most recent to least recent.
* Default `0` (last used move) * Default `0` (last used move)
@ -22,12 +22,12 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export function toHaveUsedMove( export function toHaveUsedMove(
this: MatcherState, this: MatcherState,
received: unknown, received: unknown,
expectedMove: MoveId | AtLeastOne<TurnMove>, expectedResult: MoveId | AtLeastOne<TurnMove>,
index = 0, index = 0,
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }
@ -37,33 +37,34 @@ export function toHaveUsedMove(
if (move === undefined) { if (move === undefined) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected ${pkmName} to have used ${index + 1} moves, but it didn't!`, message: () => `Expected ${pkmName} to have used ${index + 1} moves, but it didn't!`,
actual: received.getLastXMoves(-1), actual: received.getLastXMoves(-1),
}; };
} }
// Coerce to a `TurnMove` // Coerce to a `TurnMove`
if (typeof expectedMove === "number") { if (typeof expectedResult === "number") {
expectedMove = { move: expectedMove }; expectedResult = { move: expectedResult };
} }
const moveIndexStr = index === 0 ? "last move" : `${getOrdinal(index)} most recent move`; const moveIndexStr = index === 0 ? "last move" : `${getOrdinal(index)} most recent move`;
const pass = this.equals(move, expectedMove, [ const pass = this.equals(move, expectedResult, [
...this.customTesters, ...this.customTesters,
this.utils.subsetEquality, this.utils.subsetEquality,
this.utils.iterableEquality, this.utils.iterableEquality,
]); ]);
const expectedStr = getOnelineDiffStr.call(this, expectedMove); const expectedStr = getOnelineDiffStr.call(this, expectedResult);
return { return {
pass, pass,
message: () => message: () =>
pass pass
? `Expected ${pkmName}'s ${moveIndexStr} to NOT match ${expectedStr}, but it did!` ? `Expected ${pkmName}'s ${moveIndexStr} to NOT match ${expectedStr}, but it did!`
: `Expected ${pkmName}'s ${moveIndexStr} to match ${expectedStr}, but it didn't!`, : // Replace newlines with spaces to preserve one-line ness
expected: expectedMove, `Expected ${pkmName}'s ${moveIndexStr} to match ${expectedStr}, but it didn't!`,
expected: expectedResult,
actual: move, actual: move,
}; };
} }

View File

@ -13,7 +13,7 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
/** /**
* Matcher to check the amount of PP consumed by a {@linkcode Pokemon}. * Matcher to check the amount of PP consumed by a {@linkcode Pokemon}.
* @param received - The actual value received. Should be a {@linkcode Pokemon} * @param received - The actual value received. Should be a {@linkcode Pokemon}
* @param moveId - The {@linkcode MoveId} that should have consumed PP * @param expectedValue - The {@linkcode MoveId} that should have consumed PP
* @param ppUsed - The numerical amount of PP that should have been consumed, * @param ppUsed - The numerical amount of PP that should have been consumed,
* or `all` to indicate the move should be _out_ of PP * or `all` to indicate the move should be _out_ of PP
* @returns Whether the matcher passed * @returns Whether the matcher passed
@ -23,12 +23,12 @@ import type { MatcherState, SyncExpectationResult } from "@vitest/expect";
export function toHaveUsedPP( export function toHaveUsedPP(
this: MatcherState, this: MatcherState,
received: unknown, received: unknown,
moveId: MoveId, expectedMove: MoveId,
ppUsed: number | "all", ppUsed: number | "all",
): SyncExpectationResult { ): SyncExpectationResult {
if (!isPokemonInstance(received)) { if (!isPokemonInstance(received)) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`,
}; };
} }
@ -36,22 +36,22 @@ export function toHaveUsedPP(
const override = received.isPlayer() ? Overrides.MOVESET_OVERRIDE : Overrides.ENEMY_MOVESET_OVERRIDE; const override = received.isPlayer() ? Overrides.MOVESET_OVERRIDE : Overrides.ENEMY_MOVESET_OVERRIDE;
if (coerceArray(override).length > 0) { if (coerceArray(override).length > 0) {
return { return {
pass: this.isNot, pass: false,
message: () => message: () =>
`Cannot test for PP consumption with ${received.isPlayer() ? "player" : "enemy"} moveset overrides active!`, `Cannot test for PP consumption with ${received.isPlayer() ? "player" : "enemy"} moveset overrides active!`,
}; };
} }
const pkmName = getPokemonNameWithAffix(received); const pkmName = getPokemonNameWithAffix(received);
const moveStr = getEnumStr(MoveId, moveId); const moveStr = getEnumStr(MoveId, expectedMove);
const movesetMoves = received.getMoveset().filter(pm => pm.moveId === moveId); const movesetMoves = received.getMoveset().filter(pm => pm.moveId === expectedMove);
if (movesetMoves.length !== 1) { if (movesetMoves.length !== 1) {
return { return {
pass: this.isNot, pass: false,
message: () => message: () =>
`Expected MoveId.${moveStr} to appear in ${pkmName}'s moveset exactly once, but got ${movesetMoves.length} times!`, `Expected MoveId.${moveStr} to appear in ${pkmName}'s moveset exactly once, but got ${movesetMoves.length} times!`,
expected: moveId, expected: expectedMove,
actual: received.getMoveset(), actual: received.getMoveset(),
}; };
} }

View File

@ -20,15 +20,15 @@ export function toHaveWeather(
): SyncExpectationResult { ): SyncExpectationResult {
if (!isGameManagerInstance(received)) { if (!isGameManagerInstance(received)) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected to receive a GameManager, but got ${receivedStr(received)}!`, message: () => `Expected GameManager, but got ${receivedStr(received)}!`,
}; };
} }
if (!received.scene?.arena) { if (!received.scene?.arena) {
return { return {
pass: this.isNot, pass: false,
message: () => `Expected GameManager.${received.scene ? "scene.arena" : "scene"} to be defined!`, message: () => `Expected GameManager.${received.scene ? "scene" : "scene.arena"} to be defined!`,
}; };
} }
@ -41,8 +41,8 @@ export function toHaveWeather(
pass, pass,
message: () => message: () =>
pass pass
? `Expected the Arena to NOT have ${expectedStr} weather active, but it did!` ? `Expected Arena to NOT have ${expectedStr} weather active, but it did!`
: `Expected the Arena to have ${expectedStr} weather active, but got ${actualStr} instead!`, : `Expected Arena to have ${expectedStr} weather active, but got ${actualStr} instead!`,
expected: expectedWeatherType, expected: expectedWeatherType,
actual, actual,
}; };

View File

@ -34,10 +34,10 @@ interface getEnumStrOptions {
* @returns The stringified representation of `val` as dictated by the options. * @returns The stringified representation of `val` as dictated by the options.
* @example * @example
* ```ts * ```ts
* enum testEnum { * enum fakeEnum {
* ONE = 1, * ONE: 1,
* TWO = 2, * TWO: 2,
* THREE = 3, * THREE: 3,
* } * }
* getEnumStr(fakeEnum, fakeEnum.ONE); // Output: "ONE (=1)" * getEnumStr(fakeEnum, fakeEnum.ONE); // Output: "ONE (=1)"
* getEnumStr(fakeEnum, fakeEnum.TWO, {casing: "Title", prefix: "fakeEnum.", suffix: "!!!"}); // Output: "fakeEnum.TWO!!! (=2)" * getEnumStr(fakeEnum, fakeEnum.TWO, {casing: "Title", prefix: "fakeEnum.", suffix: "!!!"}); // Output: "fakeEnum.TWO!!! (=2)"
@ -174,14 +174,10 @@ export function getStatName(s: Stat): string {
* Convert an object into a oneline diff to be shown in an error message. * Convert an object into a oneline diff to be shown in an error message.
* @param obj - The object to return the oneline diff of * @param obj - The object to return the oneline diff of
* @returns The updated diff * @returns The updated diff
* @example
* ```ts
* const diff = getOnelineDiffStr.call(this, obj)
* ```
*/ */
export function getOnelineDiffStr(this: MatcherState, obj: unknown): string { export function getOnelineDiffStr(this: MatcherState, obj: unknown): string {
return this.utils return this.utils
.stringify(obj, undefined, { maxLength: 35, indent: 0, printBasicPrototype: false }) .stringify(obj, undefined, { maxLength: 35, indent: 0, printBasicPrototype: false })
.replace(/\n/g, " ") // Replace newlines with spaces .replace(/\n/g, " ") // Replace newlines with spaces
.replace(/,(\s*)}$/g, "$1}"); // Trim trailing commas .replace(/,(\s*)}$/g, "$1}");
} }