diff --git a/scripts/create-test/boilerplates/reward.ts b/scripts/create-test/boilerplates/rewards/reward.ts similarity index 71% rename from scripts/create-test/boilerplates/reward.ts rename to scripts/create-test/boilerplates/rewards/reward.ts index a4e31c07950..0a6473b1a29 100644 --- a/scripts/create-test/boilerplates/reward.ts +++ b/scripts/create-test/boilerplates/rewards/reward.ts @@ -1,8 +1,8 @@ import { AbilityId } from "#enums/ability-id"; +import { getHeldItemCategory, HeldItemCategoryId } from "#enums/held-item-id"; import { MoveId } from "#enums/move-id"; import { RewardId } from "#enums/reward-id"; import { SpeciesId } from "#enums/species-id"; -import { BerryHeldItem } from "#items/berry"; import { HeldItemReward } from "#items/reward"; import { GameManager } from "#test/test-utils/game-manager"; import { generateRewardForTest } from "#test/test-utils/reward-test-utils"; @@ -39,9 +39,13 @@ describe("{{description}}", () => { it("should do XYZ when applied", async () => { await game.classicMode.startBattle([SpeciesId.FEEBAS]); - const reward = generateRewardForTest(RewardId.BERRY); - expect(reward).toBeInstanceOf(HeldItemReward); - game.scene.applyReward(reward, []); - expect(true).toBe(true); + const feebas = game.field.getPlayerPokemon(); + + const reward = generateRewardForTest(RewardId.BERRY)!; + expect(reward).toBeInstanceOf(HeldItemReward); // Replace with actual reward instance + expect(getHeldItemCategory(reward["itemId"])).toBe(HeldItemCategoryId.BERRY); + game.scene.applyReward(reward, { pokemon: feebas }); + + expect(feebas).toHaveHeldItem(HeldItemCategoryId.BERRY); }); }); diff --git a/scripts/create-test/create-test.js b/scripts/create-test/create-test.js index 765993959d1..8b30172d422 100644 --- a/scripts/create-test/create-test.js +++ b/scripts/create-test/create-test.js @@ -101,8 +101,8 @@ async function promptFileName(selectedType) { */ function getBoilerplatePath(choiceType) { switch (choiceType) { - // case "Reward": - // return path.join(__dirname, "boilerplates/reward.ts"); + case "Reward": + return path.join(__dirname, "boilerplates/rewards/reward.ts"); default: return path.join(__dirname, "boilerplates/default.ts"); } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 2fece7de823..5de8588fb19 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -275,7 +275,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { private shinySparkle: Phaser.GameObjects.Sprite; - public heldItemManager: HeldItemManager; + public readonly heldItemManager: HeldItemManager = new HeldItemManager(); // TODO: Rework this eventually constructor( diff --git a/src/items/all-held-items.ts b/src/items/all-held-items.ts index d505a28bb52..5ca6871f44f 100644 --- a/src/items/all-held-items.ts +++ b/src/items/all-held-items.ts @@ -185,6 +185,7 @@ type ApplyHeldItemsParams = { export function applyHeldItems(effect: T, params: ApplyHeldItemsParams[T]) { const pokemon = params.pokemon; if (pokemon) { + // TODO: Make this use `getHeldItems` and make `heldItems` array private for (const item of Object.keys(pokemon.heldItemManager.heldItems)) { if (allHeldItems[item].effects.includes(effect)) { allHeldItems[item].apply(params); diff --git a/src/items/held-item-data-types.ts b/src/items/held-item-data-types.ts index af4a3054bac..9d9b65364e8 100644 --- a/src/items/held-item-data-types.ts +++ b/src/items/held-item-data-types.ts @@ -32,7 +32,7 @@ export function isHeldItemSpecs(entry: any): entry is HeldItemSpecs { } // Types used for form change items -interface FormChangeItemData { +export interface FormChangeItemData { active: boolean; } diff --git a/src/items/held-item-manager.ts b/src/items/held-item-manager.ts index 0ec85621c25..6102c570525 100644 --- a/src/items/held-item-manager.ts +++ b/src/items/held-item-manager.ts @@ -8,6 +8,7 @@ import { isItemInRequested, } from "#enums/held-item-id"; import { + type FormChangeItemData, type FormChangeItemPropertyMap, type FormChangeItemSpecs, type HeldItemConfiguration, @@ -16,9 +17,10 @@ import { type HeldItemSpecs, isHeldItemSpecs, } from "#items/held-item-data-types"; -import { getTypedEntries, getTypedKeys } from "#utils/common"; +import { getTypedKeys } from "#utils/common"; export class HeldItemManager { + // TODO: There should be a way of making these private... public heldItems: HeldItemDataMap; public formChangeItems: FormChangeItemPropertyMap; @@ -41,13 +43,14 @@ export class HeldItemManager { generateHeldItemConfiguration(restrictedIds?: HeldItemId[]): HeldItemConfiguration { const config: HeldItemConfiguration = []; - for (const [id, item] of getTypedEntries(this.heldItems)) { + for (const [id, item] of this.getHeldItemEntries()) { + // TODO: `in` breaks with arrays if (item && (!restrictedIds || id in restrictedIds)) { const specs: HeldItemSpecs = { ...item, id }; config.push({ entry: specs, count: 1 }); } } - for (const [id, item] of getTypedEntries(this.formChangeItems)) { + for (const [id, item] of this.getFormChangeItemEntries()) { if (item) { const specs: FormChangeItemSpecs = { ...item, id }; config.push({ entry: specs, count: 1 }); @@ -58,13 +61,13 @@ export class HeldItemManager { generateSaveData(): HeldItemSaveData { const saveData: HeldItemSaveData = []; - for (const [id, item] of getTypedEntries(this.heldItems)) { + for (const [id, item] of this.getHeldItemEntries()) { if (item) { const specs: HeldItemSpecs = { ...item, id }; saveData.push(specs); } } - for (const [id, item] of getTypedEntries(this.formChangeItems)) { + for (const [id, item] of this.getFormChangeItemEntries()) { if (item) { const specs: FormChangeItemSpecs = { ...item, id }; saveData.push(specs); @@ -77,28 +80,32 @@ export class HeldItemManager { return getTypedKeys(this.heldItems); } + private getHeldItemEntries(): [HeldItemId, HeldItemSpecs][] { + return Object.entries(this.heldItems) as unknown as [HeldItemId, HeldItemSpecs][]; + } + getTransferableHeldItems(): HeldItemId[] { - return getTypedKeys(this.heldItems).filter(k => allHeldItems[k].isTransferable); + return this.getHeldItems().filter(k => allHeldItems[k].isTransferable); } getStealableHeldItems(): HeldItemId[] { - return getTypedKeys(this.heldItems).filter(k => allHeldItems[k].isStealable); + return this.getHeldItems().filter(k => allHeldItems[k].isStealable); } getSuppressableHeldItems(): HeldItemId[] { - return getTypedKeys(this.heldItems).filter(k => allHeldItems[k].isSuppressable); + return this.getHeldItems().filter(k => allHeldItems[k].isSuppressable); } hasItem(itemType: HeldItemId | HeldItemCategoryId): boolean { if (isCategoryId(itemType)) { - return getTypedKeys(this.heldItems).some(id => isItemInCategory(id, itemType as HeldItemCategoryId)); + return this.getHeldItems().some(id => isItemInCategory(id, itemType as HeldItemCategoryId)); } return itemType in this.heldItems; } hasTransferableItem(itemType: HeldItemId | HeldItemCategoryId): boolean { if (isCategoryId(itemType)) { - return getTypedKeys(this.heldItems).some( + return this.getHeldItems().some( id => isItemInCategory(id, itemType as HeldItemCategoryId) && allHeldItems[id].isTransferable, ); } @@ -128,7 +135,7 @@ export class HeldItemManager { overrideItems(newItems: HeldItemDataMap) { this.heldItems = newItems; // The following is to allow randomly generated item configs to have stack 0 - for (const [item, properties] of getTypedEntries(this.heldItems)) { + for (const [item, properties] of this.getHeldItemEntries()) { if (!properties || properties.stack <= 0) { delete this.heldItems[item]; } @@ -176,6 +183,7 @@ export class HeldItemManager { item.stack -= removeStack; if (all || item.stack <= 0) { + // TODO: Delete is bad for performance delete this.heldItems[itemType]; } } @@ -219,9 +227,14 @@ export class HeldItemManager { } getFormChangeItems(): FormChangeItem[] { + // TODO: Please stop using `map(k => k)` return getTypedKeys(this.formChangeItems).map(k => k); } + private getFormChangeItemEntries(): [FormChangeItem, FormChangeItemData | undefined][] { + return Object.entries(this.formChangeItems) as unknown as [FormChangeItem, FormChangeItemData | undefined][]; + } + getActiveFormChangeItems(): FormChangeItem[] { return this.getFormChangeItems().filter(m => this.formChangeItems[m]?.active); } diff --git a/src/utils/common.ts b/src/utils/common.ts index 84742609fb2..790439ef783 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -267,6 +267,7 @@ export function formatStat(stat: number, forHp = false): string { return formatLargeNumber(stat, forHp ? 100_000 : 1_000_000); } +// TODO: Remove in place of enum utils export function getTypedKeys, K extends number = Extract>(obj: T): K[] { return Object.keys(obj).map(k => Number(k) as K); } diff --git a/test/@types/test-helpers.ts b/test/@types/test-helpers.ts new file mode 100644 index 00000000000..4db85edcb5d --- /dev/null +++ b/test/@types/test-helpers.ts @@ -0,0 +1,27 @@ +import type { AtLeastOne, NonFunctionPropertiesRecursive as nonFunc } from "#types/type-helpers"; + +/** + * Helper type to admit an object containing the given properties + * _and at least 1 other non-function property_. + * @example + * ```ts + * type foo = { + * qux: 1 | 2 | 3, + * bar: number, + * baz: string + * quux: () => void; // ignored! + * } + * + * type quxAndSomethingElse = OneOther + * + * const good1: quxAndSomethingElse = {qux: 1, bar: 3} // OK! + * const good2: quxAndSomethingElse = {qux: 2, baz: "4", bar: 12} // OK! + * const bad1: quxAndSomethingElse = {baz: "4", bar: 12} // Errors because `qux` is required + * const bad2: quxAndSomethingElse = {qux: 1} // Errors because at least 1 thing _other_ than `qux` is required + * ``` + * @typeParam O - The object to source keys from + * @typeParam K - One or more of O's keys to render mandatory + */ +export type OneOther = AtLeastOne, K>> & { + [key in K]: O[K]; +}; diff --git a/test/@types/vitest.d.ts b/test/@types/vitest.d.ts index 7b756c45a57..704165ab40a 100644 --- a/test/@types/vitest.d.ts +++ b/test/@types/vitest.d.ts @@ -15,6 +15,7 @@ import type { AtLeastOne } from "#types/type-helpers"; import type { expect } from "vitest"; import type Overrides from "#app/overrides"; import type { PokemonMove } from "#moves/pokemon-move"; +import { expectedHeldItemType } from "#test/test-utils/matchers/to-have-held-item"; declare module "vitest" { interface Assertion { @@ -135,5 +136,13 @@ declare module "vitest" { * or contains the desired move more than once, this will fail the test. */ toHaveUsedPP(expectedMove: MoveId, ppUsed: number | "all"): void; + + /** + * Check whether a {@linkcode Pokemon} has a given held item. + * @param received - The object to check. Should be a {@linkcode Pokemon}. + * @param expectedItem - A {@linkcode HeldItemId} or {@linkcode HeldItemCategoryId} to check, or a partially filled + * {@linkcode HeldItemSpecs} containing the desired values + */ + toHaveHeldItem(expected: expectedHeldItemType): void; } } diff --git a/test/matchers.setup.ts b/test/matchers.setup.ts index 03b29302916..5b65ae96d7c 100644 --- a/test/matchers.setup.ts +++ b/test/matchers.setup.ts @@ -4,6 +4,7 @@ import { toHaveBattlerTag } from "#test/test-utils/matchers/to-have-battler-tag" import { toHaveEffectiveStat } from "#test/test-utils/matchers/to-have-effective-stat"; import { toHaveFainted } from "#test/test-utils/matchers/to-have-fainted"; import { toHaveFullHp } from "#test/test-utils/matchers/to-have-full-hp"; +import { toHaveHeldItem } from "#test/test-utils/matchers/to-have-held-item"; import { toHaveHp } from "#test/test-utils/matchers/to-have-hp"; import { toHaveStatStage } from "#test/test-utils/matchers/to-have-stat-stage"; import { toHaveStatusEffect } from "#test/test-utils/matchers/to-have-status-effect"; @@ -36,4 +37,5 @@ expect.extend({ toHaveHp, toHaveFainted, toHaveUsedPP, + toHaveHeldItem, }); diff --git a/test/test-utils/matchers/to-have-held-item.ts b/test/test-utils/matchers/to-have-held-item.ts new file mode 100644 index 00000000000..3cf0c40f0d4 --- /dev/null +++ b/test/test-utils/matchers/to-have-held-item.ts @@ -0,0 +1,103 @@ +import { getPokemonNameWithAffix } from "#app/messages"; +import { HeldItemCategoryId, HeldItemId, isCategoryId } from "#enums/held-item-id"; +// biome-ignore lint/correctness/noUnusedImports: TSDoc +import type { Pokemon } from "#field/pokemon"; +import type { HeldItemSpecs } from "#items/held-item-data-types"; +import type { OneOther } from "#test/@types/test-helpers"; +import { getOnelineDiffStr, stringifyEnumArray } from "#test/test-utils/string-utils"; +import { isPokemonInstance, receivedStr } from "#test/test-utils/test-utils"; +import { enumValueToKey } from "#utils/enums"; +import type { MatcherState, SyncExpectationResult } from "@vitest/expect"; + +export type expectedHeldItemType = HeldItemId | HeldItemCategoryId | OneOther; + +/** + * Matcher that checks if a {@linkcode Pokemon} has a given held item. + * @param received - The object to check. Should be a {@linkcode Pokemon}. + * @param expectedItem - A {@linkcode HeldItemId} or {@linkcode HeldItemCategoryId} to check, or a partially filled + * {@linkcode HeldItemSpecs} containing the desired values + * @returns Whether the matcher passed + */ +export function toHaveHeldItem( + this: MatcherState, + received: unknown, + // Simplified typing; full one is in overloads + expectedItem: HeldItemId | HeldItemCategoryId | (Partial & { id: HeldItemId }), +): SyncExpectationResult { + if (!isPokemonInstance(received)) { + return { + pass: this.isNot, + message: () => `Expected to receive a Pokémon, but got ${receivedStr(received)}!`, + }; + } + + const pkmName = getPokemonNameWithAffix(received); + + // If a category was requested OR we lack the item in question, show an error message. + if (typeof expectedItem === "number" || !received.heldItemManager.hasItem(expectedItem.id)) { + expectedItem = typeof expectedItem === "number" ? expectedItem : expectedItem.id; + + const pass = received.heldItemManager.hasItem(expectedItem); + + const actualStr = stringifyEnumArray(HeldItemId, received.heldItemManager.getHeldItems(), toHexStr); + const expectedStr = itemIdToString(expectedItem); + + return { + pass, + // "Expected Magikarp to have an item with category HeldItemCategory.BERRY (=0xADAD), but it didn't!" + message: () => + pass + ? `Expected ${pkmName} to NOT have an item with ${expectedStr}, but it did!` + : `Expected ${pkmName} to have an item with ${expectedStr}, but it didn't!`, + expected: expectedStr, + actual: actualStr, + }; + } + + // Check the properties of the requested held item + const items = Object.values(received.heldItemManager["heldItems"]); + const pass = items.some(d => + this.equals(d, expectedItem, [...this.customTesters, this.utils.subsetEquality, this.utils.iterableEquality]), + ); + + // Convert item IDs in the diff into actual numbers + const expectedReadable = { + ...expectedItem, + id: toHexStr(expectedItem.id), + }; + const actualReadable = received.heldItemManager["getHeldItemEntries"]().map(([id, spec]) => ({ + ...spec, + id: toHexStr(id), + })); + const expectedStr = getOnelineDiffStr.call(this, expectedReadable); + + return { + pass, + message: () => + pass + ? `Expected ${pkmName} to NOT have an item matching ${expectedStr}, but it did!` + : `Expected ${pkmName} to have an item matching ${expectedStr}, but it didn't!`, + expected: expectedReadable, + actual: actualReadable, + }; +} + +const PADDING = 4; + +/** + * Convert a number into a readable hexadecimal format. + * @param num - The number to convert + * @returns The hex string + */ +function toHexStr(num: number): string { + return `0x${num.toString(16).padStart(PADDING, "0")}`; +} + +function itemIdToString(id: HeldItemId | HeldItemCategoryId): string { + if (isCategoryId(id)) { + const catStr = enumValueToKey(HeldItemCategoryId, id); + return `catgeory HeldItemCategory.${catStr} (=${toHexStr(id)})`; + } + const idStr = enumValueToKey(HeldItemId, id); + return `ID HeldItemId.${idStr} (=${toHexStr(id)})`; +} diff --git a/test/test-utils/string-utils.ts b/test/test-utils/string-utils.ts index bd3dd7c2fa9..731303ade2f 100644 --- a/test/test-utils/string-utils.ts +++ b/test/test-utils/string-utils.ts @@ -73,8 +73,9 @@ export function getEnumStr( /** * Convert an array of enums or `const object`s into a readable string version. - * @param obj - The {@linkcode EnumOrObject} to source reverse mappings from + * @param obj - The {@linkcode NormalEnum} to source reverse mappings from * @param enums - An array of {@linkcode obj}'s values + * @param transformValues - An optional function used to transform `obj`'s values into strings. * @returns The stringified representation of `enums`. * @example * ```ts @@ -86,12 +87,16 @@ export function getEnumStr( * console.log(stringifyEnumArray(fakeEnum, [fakeEnum.ONE, fakeEnum.TWO, fakeEnum.THREE])); // Output: "[ONE, TWO, THREE] (=[1, 2, 3])" * ``` */ -export function stringifyEnumArray(obj: E, enums: E[keyof E][]): string { +export function stringifyEnumArray( + obj: E, + enums: ObjectValues[], + transformValues?: (val: (typeof enums)[number]) => string, +): string { if (obj.length === 0) { return "[]"; } - const vals = enums.slice(); + const vals = transformValues ? enums.map(transformValues) : enums; /** An array of string names */ let names: string[]; diff --git a/test/test-utils/utils/reward-test-utils.ts b/test/test-utils/utils/reward-test-utils.ts new file mode 100644 index 00000000000..8f65107549f --- /dev/null +++ b/test/test-utils/utils/reward-test-utils.ts @@ -0,0 +1,65 @@ +import type { HeldItemId } from "#enums/held-item-id"; +import type { RewardId } from "#enums/reward-id"; +import type { TrainerItemId } from "#enums/trainer-item-id"; +import { allRewards, type allRewardsType } from "#items/all-rewards"; +import { HeldItemReward, type Reward, RewardGenerator, TrainerItemReward } from "#items/reward"; +import { isHeldItemId, isTrainerItemId } from "#items/reward-utils"; +import type { RewardPoolId, RewardSpecs } from "#types/rewards"; + +// Type used to convert allRewards into a type +type allRewardsRewardType = { + [k in keyof allRewardsType]: allRewardsType[k] extends RewardGenerator + ? ReturnType + : allRewardsType[k]; +}; + +/** + * Dynamically generate a {@linkcode Reward} from a given RewardSpecs. + * @param specs - The {@linkcode RewardSpecs} used to generate the reward + * @returns The generated {@linkcode Reward}, or `null` if no reward could be generated + * @todo Remove `null` from signature eventually + * @example + * ```ts + * const reward = generateRewardForTest({id: RewardId.BERRY, args: BerryType.SITRUS}); + * ``` + */ +export function generateRewardForTest(specs: RewardSpecs): allRewardsRewardType[T] | null; +/** + * Dynamically generate a {@linkcode Reward} from a given HeldItemId. + * @param id - The {@linkcode HeldItemId | ID} of the Held item to generate + * @returns The generated {@linkcode HeldItemReward}, or `null` if no reward could be generated + * @todo Remove `null` from signature eventually + * @example + * ```ts + * const reward = generateRewardForTest(HeldItemId.REVIVER_SEED); + * ``` + */ +export function generateRewardForTest(id: RewardSpecs): HeldItemReward | null; +/** + * Dynamically generate a {@linkcode Reward} from a given TrainerItemId. + * @param id - The {@linkcode TrainerItemId | ID} of the Trainer item to generate + * @returns The generated {@linkcode TrainerItemReward}, or `null` if no reward could be generated + * @todo Remove `null` from signature eventually + * @example + * ```ts + * const reward = generateRewardForTest(TrainerItemId.HEALING_CHARM); + * ``` + */ +export function generateRewardForTest(specs: RewardSpecs): TrainerItemReward | null; +export function generateRewardForTest(specs: RewardSpecs): Reward | null { + // Destructure specs into individual parameters + const pregenArgs = typeof specs === "object" ? specs.args : undefined; + const id: RewardPoolId = typeof specs === "object" ? specs.id : specs; + + if (isHeldItemId(id)) { + return new HeldItemReward(id); + } + + if (isTrainerItemId(id)) { + return new TrainerItemReward(id); + } + + const rewardFunc = allRewards[id]; + // @ts-expect-error - We enforce call safety using overloads + return rewardFunc instanceof RewardGenerator ? rewardFunc.generateReward(pregenArgs) : rewardFunc; +} diff --git a/tsconfig.json b/tsconfig.json index 471c1034996..72cc98a74a6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -54,6 +54,7 @@ "#utils/*": ["./utils/*.ts"], "#data/*": ["./data/pokemon-forms/*.ts", "./data/pokemon/*.ts", "./data/*.ts"], "#test/*": ["../test/*.ts"], + "#test/test-utils/*": ["../test/test-utils/utils/*.ts", "../test/test-utils/*.ts"], "#app/*": ["*.ts"] }, "outDir": "./build",