[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>
This commit is contained in:
Sirz Benjie 2025-09-15 23:26:57 -05:00 committed by GitHub
parent 85c38dfdbe
commit 0c921cdb4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 373 additions and 5 deletions

View File

@ -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<MoveId, number> {
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<MoveId, number>, 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<MoveId, number>): 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<MoveId, number>, 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<string, number>();
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,
});
}

View File

@ -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

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

@ -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;
}

View File

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