pokerogue/test/ai/ai-moveset-gen.test.ts
Sirz Benjie 0c921cdb4a
[Tests][Bug][Beta] Fix ditto bug and add unit tests (#6559)
* Fix ditto bug and add unit tests

Co-authored-by: Bertie690 <136088738+Bertie690@users.noreply.github.com>
2025-09-15 23:26:57 -05:00

286 lines
9.4 KiB
TypeScript

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, number>([
[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, number>([
[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, number>([
[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, number>([
[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, number>([
[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, number>([
[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();
});
});
});