diff --git a/src/ai/ai-moveset-gen.ts b/src/ai/ai-moveset-gen.ts index f46df09b33e..f392ca46d3f 100644 --- a/src/ai/ai-moveset-gen.ts +++ b/src/ai/ai-moveset-gen.ts @@ -1,6 +1,7 @@ import { globalScene } from "#app/global-scene"; import { speciesEggMoves } from "#balance/egg-moves"; import { + BASE_LEVEL_WEIGHT_OFFSET, BASE_WEIGHT_MULTIPLIER, BOSS_EXTRA_WEIGHT_MULTIPLIER, COMMON_TIER_TM_LEVEL_REQUIREMENT, @@ -72,7 +73,7 @@ function getAndWeightLevelMoves(pokemon: Pokemon): Map { continue; } - let weight = learnLevel + 20; + let weight = learnLevel + BASE_LEVEL_WEIGHT_OFFSET; switch (learnLevel) { case EVOLVE_MOVE: weight = EVOLUTION_MOVE_WEIGHT; @@ -132,6 +133,11 @@ function getTmPoolForSpecies( ): void { const [allowCommon, allowGreat, allowUltra] = allowedTiers; const tms = speciesTmMoves[speciesId]; + // Species with no learnable TMs (e.g. Ditto) don't have entries in the `speciesTmMoves` object, + // so this is needed to avoid iterating over `undefined` + if (tms == null) { + return; + } let moveId: MoveId; for (const tm of tms) { @@ -241,7 +247,11 @@ function getEggPoolForSpecies( excludeRare: boolean, rareEggMoveWeight = 0, ): void { - for (const [idx, moveId] of speciesEggMoves[rootSpeciesId].entries()) { + const eggMoves = speciesEggMoves[rootSpeciesId]; + if (eggMoves == null) { + return; + } + for (const [idx, moveId] of eggMoves.entries()) { if (levelPool.has(moveId) || (idx === 3 && excludeRare)) { continue; } @@ -416,7 +426,6 @@ function adjustDamageMoveWeights(pool: Map, pokemon: Pokemon, wi * @param pool - The move pool to calculate the total weight for * @returns The total weight of all moves in the pool */ -// biome-ignore lint/correctness/noUnusedVariables: May be useful function calculateTotalPoolWeight(pool: Map): number { let totalWeight = 0; for (const weight of pool.values()) { @@ -622,7 +631,7 @@ function fillInRemainingMovesetSlots( * @param note - Short note to include in the log for context */ function debugMoveWeights(pokemon: Pokemon, pool: Map, note: string): void { - if (isBeta || import.meta.env.DEV) { + if ((isBeta || import.meta.env.DEV) && import.meta.env.NODE_ENV !== "test") { const moveNameToWeightMap = new Map(); const sortedByValue = Array.from(pool.entries()).sort((a, b) => b[1] - a[1]); for (const [moveId, weight] of sortedByValue) { @@ -713,3 +722,49 @@ export function generateMoveset(pokemon: Pokemon): void { filterPool(baseWeights, (m: MoveId) => !pokemon.moveset.some(mo => m[0] === mo.moveId)), ); } + +/** + * Exports for internal testing purposes. + * ⚠️ These *must not* be used outside of tests, as they will not be defined. + * @internal + */ +export const __INTERNAL_TEST_EXPORTS: { + getAndWeightLevelMoves: typeof getAndWeightLevelMoves; + getAllowedTmTiers: typeof getAllowedTmTiers; + getTmPoolForSpecies: typeof getTmPoolForSpecies; + getAndWeightTmMoves: typeof getAndWeightTmMoves; + getEggMoveWeight: typeof getEggMoveWeight; + getEggPoolForSpecies: typeof getEggPoolForSpecies; + getAndWeightEggMoves: typeof getAndWeightEggMoves; + filterMovePool: typeof filterMovePool; + adjustWeightsForTrainer: typeof adjustWeightsForTrainer; + adjustDamageMoveWeights: typeof adjustDamageMoveWeights; + calculateTotalPoolWeight: typeof calculateTotalPoolWeight; + filterPool: typeof filterPool; + forceStabMove: typeof forceStabMove; + filterRemainingTrainerMovePool: typeof filterRemainingTrainerMovePool; + fillInRemainingMovesetSlots: typeof fillInRemainingMovesetSlots; +} = {} as any; + +// We can't use `import.meta.vitest` here, because this would not be set +// until the tests themselves begin to run, which is after imports +// So we rely on NODE_ENV being test instead +if (import.meta.env.NODE_ENV === "test") { + Object.assign(__INTERNAL_TEST_EXPORTS, { + getAndWeightLevelMoves, + getAllowedTmTiers, + getTmPoolForSpecies, + getAndWeightTmMoves, + getEggMoveWeight, + getEggPoolForSpecies, + getAndWeightEggMoves, + filterMovePool, + adjustWeightsForTrainer, + adjustDamageMoveWeights, + calculateTotalPoolWeight, + filterPool, + forceStabMove, + filterRemainingTrainerMovePool, + fillInRemainingMovesetSlots, + }); +} diff --git a/src/data/balance/moveset-generation.ts b/src/data/balance/moveset-generation.ts index f9c2a03f4a9..90a602ca97e 100644 --- a/src/data/balance/moveset-generation.ts +++ b/src/data/balance/moveset-generation.ts @@ -83,6 +83,33 @@ export const GREAT_TM_MOVESET_WEIGHT = 14; /** The weight given to TMs in the ultra tier during moveset generation */ export const ULTRA_TM_MOVESET_WEIGHT = 18; +/** + * The base weight offset for level moves + * + * @remarks + * The relative likelihood of moves learned at different levels is determined by + * the ratio of their weights, + * or, the formula: + * `(levelB + BASE_LEVEL_WEIGHT_OFFSET) / (levelA + BASE_LEVEL_WEIGHT_OFFSET)` + * + * For example, consider move A and B that are learned at levels 1 and 60, respectively, + * but have no other differences (same power, accuracy, category, etc). + * The following table demonstrates the likelihood of move B being chosen over move A. + * + * | Offset | Likelihood | + * |--------|------------| + * | 0 | 60x | + * | 1 | 30x | + * | 5 | 10.8x | + * | 20 | 3.8x | + * | 60 | 2x | + * + * Note that increasing this without adjusting the other weights will decrease the likelihood of non-level moves + * + * For a complete picture, see {@link https://www.desmos.com/calculator/wgln4dxigl} + */ +export const BASE_LEVEL_WEIGHT_OFFSET = 20; + /** * The maximum weight an egg move can ever have * @remarks diff --git a/src/vite.env.d.ts b/src/vite.env.d.ts index 68159908730..3192b81afd3 100644 --- a/src/vite.env.d.ts +++ b/src/vite.env.d.ts @@ -9,8 +9,9 @@ interface ImportMetaEnv { readonly VITE_DISCORD_CLIENT_ID?: string; readonly VITE_GOOGLE_CLIENT_ID?: string; readonly VITE_I18N_DEBUG?: string; + readonly NODE_ENV?: string; } -interface ImportMeta { +declare interface ImportMeta { readonly env: ImportMetaEnv; } diff --git a/test/ai/ai-moveset-gen.test.ts b/test/ai/ai-moveset-gen.test.ts new file mode 100644 index 00000000000..6d927926131 --- /dev/null +++ b/test/ai/ai-moveset-gen.test.ts @@ -0,0 +1,285 @@ +import { __INTERNAL_TEST_EXPORTS } from "#app/ai/ai-moveset-gen"; +import { + COMMON_TIER_TM_LEVEL_REQUIREMENT, + GREAT_TIER_TM_LEVEL_REQUIREMENT, + ULTRA_TIER_TM_LEVEL_REQUIREMENT, +} from "#balance/moveset-generation"; +import { allMoves, allSpecies } from "#data/data-lists"; +import { MoveId } from "#enums/move-id"; +import { SpeciesId } from "#enums/species-id"; +import { TrainerSlot } from "#enums/trainer-slot"; +import { EnemyPokemon } from "#field/pokemon"; +import { GameManager } from "#test/test-utils/game-manager"; +import { NumberHolder } from "#utils/common"; +import { afterEach } from "node:test"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +/** + * Parameters for {@linkcode createTestablePokemon} + */ +interface MockPokemonParams { + /** The level to set the Pokémon to */ + level: number; + /** + * Whether the pokemon is a boss or not. + * @defaultValue `false` + */ + boss?: boolean; + /** + * The trainer slot to assign to the pokemon, if any. + * @defaultValue `TrainerSlot.NONE` + */ + trainerSlot?: TrainerSlot; + /** + * The form index to assign to the pokemon, if any. + * This *must* be one of the valid form indices for the species, or the test will break. + * @defaultValue `0` + */ + formIndex?: number; +} + +/** + * Construct an `EnemyPokemon` that can be used for testing + * @param species - The species ID of the pokemon to create + * @returns The newly created `EnemyPokemon`. + * @todo Move this to a dedicated unit test util folder if more tests come to rely on it + */ +function createTestablePokemon( + species: SpeciesId, + { level, trainerSlot = TrainerSlot.NONE, boss = false, formIndex = 0 }: MockPokemonParams, +): EnemyPokemon { + const pokemon = new EnemyPokemon(allSpecies[species], level, trainerSlot, boss); + if (formIndex !== 0) { + const formIndexLength = allSpecies[species]?.forms.length; + const name = allSpecies[species]?.name; + expect(formIndex, `${name} does not have a form with index ${formIndex}`).toBeLessThan(formIndexLength); + pokemon.formIndex = formIndex; + } + + return pokemon; +} + +describe("Unit Tests - ai-moveset-gen.ts", () => { + describe("filterPool", () => { + const { filterPool } = __INTERNAL_TEST_EXPORTS; + it("clones a pool when there are no predicates", () => { + const pool = new Map([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + ]); + + const filtered = filterPool(pool, () => true); + const expected = [ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + ]; + expect(filtered).toEqual(expected); + }); + + it("does not modify the original pool", () => { + const pool = new Map([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + ]); + const original = new Map(pool); + + filterPool(pool, moveId => moveId !== MoveId.TACKLE); + expect(pool).toEqual(original); + }); + + it("filters out moves that do not match the predicate", () => { + const pool = new Map([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + [MoveId.SPLASH, 3], + ]); + const filtered = filterPool(pool, moveId => moveId !== MoveId.SPLASH); + expect(filtered).toEqual([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + ]); + }); + + it("returns an empty array if no moves match the predicate", () => { + const pool = new Map([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + ]); + const filtered = filterPool(pool, () => false); + expect(filtered).toEqual([]); + }); + + it("calculates totalWeight correctly when provided", () => { + const pool = new Map([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + [MoveId.SPLASH, 3], + ]); + const totalWeight = new NumberHolder(0); + const filtered = filterPool(pool, moveId => moveId !== MoveId.SPLASH, totalWeight); + expect(filtered).toEqual([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + ]); + expect(totalWeight.value).toBe(3); + }); + + it("Clears totalWeight when provided", () => { + const pool = new Map([ + [MoveId.TACKLE, 1], + [MoveId.FLAMETHROWER, 2], + ]); + const totalWeight = new NumberHolder(42); + const filtered = filterPool(pool, () => false, totalWeight); + expect(filtered).toEqual([]); + expect(totalWeight.value).toBe(0); + }); + }); + + describe("getAllowedTmTiers", () => { + const { getAllowedTmTiers } = __INTERNAL_TEST_EXPORTS; + + it.each([ + { tierName: "common", resIdx: 0, level: COMMON_TIER_TM_LEVEL_REQUIREMENT - 1 }, + { tierName: "great", resIdx: 1, level: GREAT_TIER_TM_LEVEL_REQUIREMENT - 1 }, + { tierName: "ultra", resIdx: 2, level: ULTRA_TIER_TM_LEVEL_REQUIREMENT - 1 }, + ])("should prevent $name TMs when below level $level", ({ level, resIdx }) => { + expect(getAllowedTmTiers(level)[resIdx]).toBe(false); + }); + + it.each([ + { tierName: "common", resIdx: 0, level: COMMON_TIER_TM_LEVEL_REQUIREMENT }, + { tierName: "great", resIdx: 1, level: GREAT_TIER_TM_LEVEL_REQUIREMENT }, + { tierName: "ultra", resIdx: 2, level: ULTRA_TIER_TM_LEVEL_REQUIREMENT }, + ])("should allow $name TMs when at level $level", ({ level, resIdx }) => { + expect(getAllowedTmTiers(level)[resIdx]).toBe(true); + }); + }); + + // Unit tests for methods that require a game context + describe("", () => { + //#region boilerplate + let phaserGame: Phaser.Game; + let game: GameManager; + /**A pokemon object that will be cleaned up after every test */ + let pokemon: EnemyPokemon | null = null; + + beforeAll(async () => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + // Game manager can be reused between tests as we are not really modifying the global state + // So there is no need to put this in a beforeEach with cleanup in afterEach. + game = new GameManager(phaserGame); + }); + + afterEach(() => { + pokemon?.destroy(); + }); + // Sanitize the interceptor after running the suite to ensure other tests are not affected + afterAll(() => { + game.phaseInterceptor.restoreOg(); + }); + //#endregion boilerplate + + function createCharmander(_ = pokemon): asserts _ is EnemyPokemon { + pokemon?.destroy(); + pokemon = createTestablePokemon(SpeciesId.CHARMANDER, { level: 10 }); + expect(pokemon).toBeInstanceOf(EnemyPokemon); + } + describe("getAndWeightLevelMoves", () => { + const { getAndWeightLevelMoves } = __INTERNAL_TEST_EXPORTS; + + it("returns an empty map if getLevelMoves throws", async () => { + createCharmander(pokemon); + vi.spyOn(pokemon, "getLevelMoves").mockImplementation(() => { + throw new Error("fail"); + }); + // Suppress the warning from the test output + const warnMock = vi.spyOn(console, "warn").mockImplementationOnce(() => {}); + + const result = getAndWeightLevelMoves(pokemon); + expect(warnMock).toHaveBeenCalled(); + expect(result.size).toBe(0); + }); + + it("skips unimplemented moves", () => { + createCharmander(pokemon); + vi.spyOn(pokemon, "getLevelMoves").mockReturnValue([ + [1, MoveId.TACKLE], + [5, MoveId.GROWL], + ]); + vi.spyOn(allMoves[MoveId.TACKLE], "name", "get").mockReturnValue("Tackle (N)"); + const result = getAndWeightLevelMoves(pokemon); + expect(result.has(MoveId.TACKLE)).toBe(false); + expect(result.has(MoveId.GROWL)).toBe(true); + }); + + it("skips moves already in the pool", () => { + createCharmander(pokemon); + vi.spyOn(pokemon, "getLevelMoves").mockReturnValue([ + [1, MoveId.TACKLE], + [5, MoveId.TACKLE], + ]); + + const result = getAndWeightLevelMoves(pokemon); + expect(result.get(MoveId.TACKLE)).toBe(21); + }); + + it("weights moves based on level", () => { + createCharmander(pokemon); + vi.spyOn(pokemon, "getLevelMoves").mockReturnValue([ + [1, MoveId.TACKLE], + [5, MoveId.GROWL], + [9, MoveId.EMBER], + ]); + + const result = getAndWeightLevelMoves(pokemon); + expect(result.get(MoveId.TACKLE)).toBe(21); + expect(result.get(MoveId.GROWL)).toBe(25); + expect(result.get(MoveId.EMBER)).toBe(29); + }); + }); + }); +}); + +describe("Regression Tests - ai-moveset-gen.ts", () => { + //#region boilerplate + let phaserGame: Phaser.Game; + let game: GameManager; + /**A pokemon object that will be cleaned up after every test */ + let pokemon: EnemyPokemon | null = null; + + beforeAll(async () => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + // Game manager can be reused between tests as we are not really modifying the global state + // So there is no need to put this in a beforeEach with cleanup in afterEach. + game = new GameManager(phaserGame); + }); + + afterEach(() => { + pokemon?.destroy(); + }); + + afterAll(() => { + game.phaseInterceptor.restoreOg(); + }); + //#endregion boilerplate + + describe("getTmPoolForSpecies", () => { + const { getTmPoolForSpecies } = __INTERNAL_TEST_EXPORTS; + + it("should not crash when generating a moveset for Pokemon without TM moves", () => { + pokemon = createTestablePokemon(SpeciesId.DITTO, { level: 50 }); + expect(() => + getTmPoolForSpecies(SpeciesId.DITTO, ULTRA_TIER_TM_LEVEL_REQUIREMENT, "", new Map(), new Map(), new Map(), [ + true, + true, + true, + ]), + ).not.toThrow(); + }); + }); +});