From 25a2fb4266ebf84470ae8549c9b6d76a31eb2194 Mon Sep 17 00:00:00 2001 From: Xavion3 Date: Mon, 15 Sep 2025 12:44:58 +1000 Subject: [PATCH 1/8] [Balance] Tweak trainer moveset TM generation. (#6533) * Lower TM weight in moveset generation. * Implement a cap on amount of TMs based on wave. * Extract ai moveset generation to its own file * Minor doc cleanup * Move magic numbers to balance file and export them * Tweak stab move weight generation --------- Co-authored-by: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> --- src/ai/ai-moveset-gen.ts | 715 +++++++++++++++++++++++++ src/data/balance/moveset-generation.ts | 209 ++++++++ src/data/trainers/trainer-config.ts | 33 +- src/field/pokemon.ts | 299 +---------- 4 files changed, 963 insertions(+), 293 deletions(-) create mode 100644 src/ai/ai-moveset-gen.ts create mode 100644 src/data/balance/moveset-generation.ts diff --git a/src/ai/ai-moveset-gen.ts b/src/ai/ai-moveset-gen.ts new file mode 100644 index 00000000000..f46df09b33e --- /dev/null +++ b/src/ai/ai-moveset-gen.ts @@ -0,0 +1,715 @@ +import { globalScene } from "#app/global-scene"; +import { speciesEggMoves } from "#balance/egg-moves"; +import { + BASE_WEIGHT_MULTIPLIER, + BOSS_EXTRA_WEIGHT_MULTIPLIER, + COMMON_TIER_TM_LEVEL_REQUIREMENT, + COMMON_TM_MOVESET_WEIGHT, + EGG_MOVE_LEVEL_REQUIREMENT, + EGG_MOVE_TO_LEVEL_WEIGHT, + EGG_MOVE_WEIGHT_MAX, + EVOLUTION_MOVE_WEIGHT, + GREAT_TIER_TM_LEVEL_REQUIREMENT, + GREAT_TM_MOVESET_WEIGHT, + getMaxEggMoveCount, + getMaxTmCount, + RARE_EGG_MOVE_LEVEL_REQUIREMENT, + STAB_BLACKLIST, + ULTRA_TIER_TM_LEVEL_REQUIREMENT, + ULTRA_TM_MOVESET_WEIGHT, +} from "#balance/moveset-generation"; +import { EVOLVE_MOVE, RELEARN_MOVE } from "#balance/pokemon-level-moves"; +import { speciesTmMoves, tmPoolTiers } from "#balance/tms"; +import { allMoves } from "#data/data-lists"; +import { ModifierTier } from "#enums/modifier-tier"; +import { MoveCategory } from "#enums/move-category"; +import type { MoveId } from "#enums/move-id"; +import { PokemonType } from "#enums/pokemon-type"; +import type { SpeciesId } from "#enums/species-id"; +import { Stat } from "#enums/stat"; +import type { EnemyPokemon, Pokemon } from "#field/pokemon"; +import { PokemonMove } from "#moves/pokemon-move"; +import { NumberHolder, randSeedInt } from "#utils/common"; +import { isBeta } from "#utils/utility-vars"; + +/** + * Compute and assign a weight to the level-up moves currently available to the Pokémon + * + * @param pokemon - The Pokémon to generate a level-based move pool for + * @returns A map of move IDs to their computed weights + * + * @remarks + * A move's weight is determined by its level, as follows: + * 1. If the level is an {@linkcode EVOLVE_MOVE} move, weight is 60 + * 2. If it is level 1 with 80+ BP, it is considered a "move reminder" move and + * weight is 60 + * 3. If the Pokémon has a trainer and the move is a {@linkcode RELEARN_MOVE}, + * weight is 60 + * 4. Otherwise, weight is the earliest level the move can be learned + 20 + */ +function getAndWeightLevelMoves(pokemon: Pokemon): Map { + const movePool = new Map(); + let allLevelMoves: [number, MoveId][]; + // TODO: Investigate why there needs to be error handling here + try { + allLevelMoves = pokemon.getLevelMoves(1, true, true, pokemon.hasTrainer()); + } catch (e) { + console.warn("Error encountered trying to generate moveset for %s: %s", pokemon.species.name, e); + return movePool; + } + + const level = pokemon.level; + const hasTrainer = pokemon.hasTrainer(); + + for (const levelMove of allLevelMoves) { + const [learnLevel, id] = levelMove; + if (level < learnLevel) { + break; + } + const move = allMoves[id]; + // Skip unimplemented moves or moves that are already in the pool + if (move.name.endsWith(" (N)") || movePool.has(id)) { + continue; + } + + let weight = learnLevel + 20; + switch (learnLevel) { + case EVOLVE_MOVE: + weight = EVOLUTION_MOVE_WEIGHT; + break; + // Assume level 1 moves with 80+ BP are "move reminder" moves and bump their weight. Trainers use actual relearn moves. + case 1: + if (move.power >= 80) { + weight = 60; + } + break; + case RELEARN_MOVE: + if (hasTrainer) { + weight = 60; + } + } + + movePool.set(id, weight); + } + + return movePool; +} + +/** + * Determine which TM tiers a Pokémon can learn based on its level + * @param level - The level of the Pokémon + * @returns A tuple indicating whether the Pokémon can learn common, great, and ultra tier TMs + */ +function getAllowedTmTiers(level: number): [common: boolean, great: boolean, ultra: boolean] { + return [ + level >= COMMON_TIER_TM_LEVEL_REQUIREMENT, + level >= GREAT_TIER_TM_LEVEL_REQUIREMENT, + level >= ULTRA_TIER_TM_LEVEL_REQUIREMENT, + ]; +} + +/** + * Get the TMs that a species can learn based on its ID and formKey + * @param speciesId - The species ID of the Pokémon + * @param level - The level of the Pokémon + * @param formKey - The form key of the Pokémon + * @param levelPool - The current level-based move pool, to avoid duplicates + * @param tmPool - The TM move pool to add to, which will be modified in place + * @param allowedTiers - The tiers of TMs the Pokémon is allowed to learn + * + * @privateRemarks + * Split out from `getAndWeightTmMoves` to allow fusion species to add their TMs + * without duplicating code. + */ +function getTmPoolForSpecies( + speciesId: number, + level: number, + formKey: string, + levelPool: ReadonlyMap, + eggPool: ReadonlyMap, + tmPool: Map, + allowedTiers = getAllowedTmTiers(level), +): void { + const [allowCommon, allowGreat, allowUltra] = allowedTiers; + const tms = speciesTmMoves[speciesId]; + + let moveId: MoveId; + for (const tm of tms) { + if (Array.isArray(tm)) { + if (tm[0] !== formKey) { + continue; + } + moveId = tm[1]; + } else { + moveId = tm; + } + + if (levelPool.has(moveId) || eggPool.has(moveId) || tmPool.has(moveId)) { + continue; + } + switch (tmPoolTiers[moveId]) { + case ModifierTier.COMMON: + allowCommon && tmPool.set(moveId, COMMON_TM_MOVESET_WEIGHT); + break; + case ModifierTier.GREAT: + allowGreat && tmPool.set(moveId, GREAT_TM_MOVESET_WEIGHT); + break; + case ModifierTier.ULTRA: + allowUltra && tmPool.set(moveId, ULTRA_TM_MOVESET_WEIGHT); + break; + } + } +} + +/** + * Compute and assign a weight to the TM moves currently available to the Pokémon + * @param pokemon - The Pokémon to generate a TM-based move pool for + * @param currentSet - The current movepool, to avoid duplicates + * @param tmPool - The TM move pool to add to, which will be modified in place + * @returns A map of move IDs to their computed weights + * + * @remarks + * Only trainer pokemon can learn TM moves, and there are restrictions + * as to how many and which TMs are available based on the level of the Pokémon. + * 1. Before level 25, no TM moves are available + * 2. Between levels 25 and 40, only COMMON tier TMs are available, + */ +function getAndWeightTmMoves( + pokemon: Pokemon, + currentPool: ReadonlyMap, + eggPool: ReadonlyMap, + tmPool: Map, +): void { + const level = pokemon.level; + const allowedTiers = getAllowedTmTiers(level); + if (!allowedTiers.includes(true)) { + return; + } + + const form = pokemon.species.forms[pokemon.formIndex]?.formKey ?? ""; + getTmPoolForSpecies(pokemon.species.speciesId, level, form, currentPool, eggPool, tmPool, allowedTiers); + const fusionFormKey = pokemon.getFusionFormKey(); + const fusionSpecies = pokemon.fusionSpecies?.speciesId; + if (fusionSpecies != null && fusionFormKey != null && fusionFormKey !== "") { + getTmPoolForSpecies(fusionSpecies, level, fusionFormKey, currentPool, eggPool, tmPool, allowedTiers); + } +} + +/** + * Get the weight multiplier for an egg move + * @param levelPool - Map of level up moves to their weights + * @param level - The level of the Pokémon + * @param forRare - Whether this is for a rare egg move + * @param isBoss - Whether the Pokémon having the egg move generated is a boss Pokémon + */ +export function getEggMoveWeight( + // biome-ignore-start lint/correctness/noUnusedFunctionParameters: Saved to allow this algorithm to be tweaked easily without adjusting signatures + levelPool: ReadonlyMap, + level: number, + forRare: boolean, + isBoss: boolean, + // biome-ignore-end lint/correctness/noUnusedFunctionParameters: Endrange +): number { + const levelUpWeightedEggMoveWeight = Math.round(Math.max(...levelPool.values()) * EGG_MOVE_TO_LEVEL_WEIGHT); + // Rare egg moves are always weighted at 5/6 the weight of normal egg moves + return Math.min(levelUpWeightedEggMoveWeight, EGG_MOVE_WEIGHT_MAX) * (forRare ? 5 / 6 : 1); +} + +/** + * Submethod of {@linkcode getAndWeightEggMoves} that adds egg moves for a specific species to the egg move pool + * + * @param rootSpeciesId - The ID of the root species for which to generate the egg move pool. + * @param levelPool - A readonly map of move IDs to their levels, representing moves already learned by leveling up. + * @param eggPool - A map to be populated with egg move IDs and their corresponding weights. + * @param eggMoveWeight - The default weight to assign to regular egg moves. + * @param excludeRare - If true, excludes rare egg moves + * @param rareEggMoveWeight - The weight to assign to rare egg moves; default 0 + * + * @privateRemarks + * Split from `getAndWeightEggMoves` to allow fusion species to add their egg moves without duplicating code. + * + * @remarks + * - Moves present in `levelPool` are excluded from the egg pool. + * - If `excludeRare` is true, rare egg moves (at index 3) are skipped. + * - Rare egg moves are assigned `rareEggMoveWeight`, while others receive `eggMoveWeight`. + */ +function getEggPoolForSpecies( + rootSpeciesId: SpeciesId, + levelPool: ReadonlyMap, + eggPool: Map, + eggMoveWeight: number, + excludeRare: boolean, + rareEggMoveWeight = 0, +): void { + for (const [idx, moveId] of speciesEggMoves[rootSpeciesId].entries()) { + if (levelPool.has(moveId) || (idx === 3 && excludeRare)) { + continue; + } + eggPool.set(Math.max(moveId, eggPool.get(moveId) ?? 0), idx === 3 ? rareEggMoveWeight : eggMoveWeight); + } +} + +/** + * Compute and assign a weight to the egg moves currently available to the Pokémon + * @param pokemon - The Pokémon to generate egg moves for + * @param levelPool - The map of level-based moves to their weights + * @param eggPool - A map of move IDs to their weights for egg moves that will be modified in place + * + * @remarks + * This function checks if the Pokémon meets the requirements to learn egg moves, + * and if allowed, calculates the weights for regular and rare egg moves using the provided pools. + */ +function getAndWeightEggMoves( + pokemon: Pokemon, + levelPool: ReadonlyMap, + eggPool: Map, +): void { + const level = pokemon.level; + if (level < EGG_MOVE_LEVEL_REQUIREMENT || !globalScene.currentBattle?.trainer?.config.allowEggMoves) { + return; + } + const isBoss = pokemon.isBoss(); + const excludeRare = isBoss || level < RARE_EGG_MOVE_LEVEL_REQUIREMENT; + const eggMoveWeight = getEggMoveWeight(levelPool, level, false, isBoss); + let rareEggMoveWeight: number | undefined; + if (!excludeRare) { + rareEggMoveWeight = getEggMoveWeight(levelPool, level, true, isBoss); + } + getEggPoolForSpecies( + pokemon.species.getRootSpeciesId(), + levelPool, + eggPool, + eggMoveWeight, + excludeRare, + rareEggMoveWeight, + ); + + const fusionSpecies = pokemon.fusionSpecies?.getRootSpeciesId(); + if (fusionSpecies != null) { + getEggPoolForSpecies(fusionSpecies, levelPool, eggPool, eggMoveWeight, excludeRare, rareEggMoveWeight); + } +} + +/** + * Filter a move pool, removing moves that are not allowed based on conditions + * @param pool - The move pool to filter + * @param isBoss - Whether the Pokémon is a boss + * @param hasTrainer - Whether the Pokémon has a trainer + */ +function filterMovePool(pool: Map, isBoss: boolean, hasTrainer: boolean): void { + for (const [moveId, weight] of pool) { + if (weight <= 0) { + pool.delete(moveId); + continue; + } + const move = allMoves[moveId]; + // Forbid unimplemented moves + if (move.name.endsWith(" (N)")) { + pool.delete(moveId); + continue; + } + // Bosses never get self ko moves or Pain Split + if (isBoss && (move.hasAttr("SacrificialAttr") || move.hasAttr("HpSplitAttr"))) { + pool.delete(moveId); + } + + // No one gets Memento or Final Gambit + if (move.hasAttr("SacrificialAttrOnHit")) { + pool.delete(moveId); + continue; + } + + // Trainers never get OHKO moves + if (hasTrainer && move.hasAttr("OneHitKOAttr")) { + pool.delete(moveId); + } + } +} + +/** + * Perform Trainer-specific adjustments to move weights in a move pool + * @param pool - The move pool to adjust + */ +function adjustWeightsForTrainer(pool: Map): void { + for (const [moveId, weight] of pool.entries()) { + const move = allMoves[moveId]; + let adjustedWeight = weight; + // Half the weight of self KO moves on trainers + adjustedWeight *= move.hasAttr("SacrificialAttr") ? 0.5 : 1; + + // Trainers get a weight bump to stat buffing moves + adjustedWeight *= move.getAttrs("StatStageChangeAttr").some(a => a.stages > 1 && a.selfTarget) ? 1.25 : 1; + + // Trainers get a weight decrease to multiturn moves + adjustedWeight *= !!move.isChargingMove() || !!move.hasAttr("RechargeAttr") ? 0.7 : 1; + if (adjustedWeight !== weight) { + pool.set(moveId, adjustedWeight); + } + } +} + +/** + * Adjust weights of damaging moves in a move pool based on their power and category + * + * @param pool - The move pool to adjust + * @param pokemon - The Pokémon for which the moveset is being generated + * @param willTera - Whether the Pokémon is expected to Tera (i.e., has instant Tera on a Trainer Pokémon); default `false` + * @remarks + * Caps max power at 90 to avoid something like hyper beam ruining the stats. + * pokemon is a pretty soft weighting factor, although it is scaled with the weight multiplier. + */ +function adjustDamageMoveWeights(pool: Map, pokemon: Pokemon, willTera = false): void { + // begin max power at 40 to avoid inflating weights too much when there are only low power moves + let maxPower = 40; + for (const moveId of pool.keys()) { + const move = allMoves[moveId]; + maxPower = Math.max(maxPower, move.calculateEffectivePower()); + if (maxPower >= 90) { + maxPower = 90; + break; + } + } + + const atk = pokemon.getStat(Stat.ATK); + const spAtk = pokemon.getStat(Stat.SPATK); + const lowerStat = Math.min(atk, spAtk); + const higherStat = Math.max(atk, spAtk); + const worseCategory = atk > spAtk ? MoveCategory.SPECIAL : MoveCategory.PHYSICAL; + const statRatio = lowerStat / higherStat; + const adjustmentRatio = Math.min(Math.pow(statRatio, 3) * 1.3, 1); + + for (const [moveId, weight] of pool) { + const move = allMoves[moveId]; + let adjustedWeight = weight; + if (move.category === MoveCategory.STATUS) { + continue; + } + // Scale weight based on their ratio to the highest power move, capping at 50% reduction + adjustedWeight *= Math.max(Math.min(move.calculateEffectivePower() / maxPower, 1), 0.5); + + // Scale weight based the stat it uses to deal damage, based on the ratio between said stat + // and the higher stat + if (move.hasAttr("DefAtkAttr")) { + const def = pokemon.getStat(Stat.DEF); + const defRatio = def / higherStat; + const defAdjustRatio = Math.min(Math.pow(defRatio, 3) * 1.3, 1.1); + adjustedWeight *= defAdjustRatio; + } else if ( + move.category === worseCategory + && !move.hasAttr("PhotonGeyserCategoryAttr") + && !move.hasAttr("ShellSideArmCategoryAttr") + && !(move.hasAttr("TeraMoveCategoryAttr") && willTera) + ) { + // Raw multiply each move's category by the stat it uses to deal damage + // moves that always use the higher offensive stat are left unadjusted + adjustedWeight *= adjustmentRatio; + } + + if (adjustedWeight !== weight) { + pool.set(moveId, adjustedWeight); + } + } +} + +/** + * Calculate the total weight of all moves in a move pool + * @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()) { + totalWeight += weight; + } + return totalWeight; +} + +/** + * Filter a pool and return a new array of moves that pass the predicate + * @param pool - The move pool to filter + * @param predicate - The predicate function to determine if a move should be included + * @param totalWeight - An output parameter to hold the total weight of the filtered pool. Its value is reset to 0 if provided. + * @returns An array of move ID and weight tuples that pass the predicate + */ +function filterPool( + pool: ReadonlyMap, + predicate: (moveId: MoveId) => boolean, + totalWeight?: NumberHolder, +): [id: MoveId, weight: number][] { + let hasTotalWeight = false; + if (totalWeight != null) { + totalWeight.value = 0; + hasTotalWeight = true; + } + const newPool: [id: MoveId, weight: number][] = []; + for (const [moveId, weight] of pool) { + if (predicate(moveId)) { + newPool.push([moveId, weight]); + if (hasTotalWeight) { + // Bang is safe here because we set `hasTotalWeight` in the if check above + totalWeight!.value += weight; + } + } + } + + return newPool; +} + +/** + * Forcibly add a STAB move to the Pokémon's moveset from the provided pools + * + * @remarks + * If no STAB move is available, add any damaging move. + * If no damaging move is available, no move is added + * @param pool - The master move pool + * @param tmPool - The TM move pool + * @param eggPool - The egg move pool + * @param pokemon - The Pokémon for which the moveset is being generated + * @param tmCount - A holder for the count of TM moves selected + * @param eggMoveCount - A holder for the count of egg moves selected + * @param willTera - Whether the Pokémon is expected to Tera (i.e., has instant Tera on a Trainer Pokémon); default `false` + * @param forceAnyDamageIfNoStab - If true, will force any damaging move if no STAB move is available + */ +// biome-ignore lint/nursery/useMaxParams: This is a complex function that needs all these parameters +function forceStabMove( + pool: Map, + tmPool: Map, + eggPool: Map, + pokemon: Pokemon, + tmCount: NumberHolder, + eggMoveCount: NumberHolder, + willTera = false, + forceAnyDamageIfNoStab = false, +): void { + // All Pokemon force a STAB move first + const totalWeight = new NumberHolder(0); + const stabMovePool = filterPool( + pool, + moveId => { + const move = allMoves[moveId]; + return ( + move.category !== MoveCategory.STATUS + && (pokemon.isOfType(move.type) + || (willTera && move.hasAttr("TeraBlastTypeAttr") && pokemon.getTeraType() !== PokemonType.STELLAR)) + && !STAB_BLACKLIST.has(moveId) + ); + }, + totalWeight, + ); + + const chosenPool = + stabMovePool.length > 0 || !forceAnyDamageIfNoStab + ? stabMovePool + : filterPool( + pool, + m => allMoves[m[0]].category !== MoveCategory.STATUS && !STAB_BLACKLIST.has(m[0]), + totalWeight, + ); + + if (chosenPool.length > 0) { + let rand = randSeedInt(totalWeight.value); + let index = 0; + while (rand > chosenPool[index][1]) { + rand -= chosenPool[index++][1]; + } + const selectedId = chosenPool[index][0]; + pool.delete(selectedId); + if (tmPool.has(selectedId)) { + tmPool.delete(selectedId); + tmCount.value++; + } else if (eggPool.has(selectedId)) { + eggPool.delete(selectedId); + eggMoveCount.value++; + } + pokemon.moveset.push(new PokemonMove(selectedId)); + } +} + +/** + * Adjust weights in the remaining move pool based on existing moves in the Pokémon's moveset + * + * @remarks + * Submethod for step 5 of moveset generation + * @param pool - The move pool to filter + * @param pokemon - The Pokémon for which the moveset is being generated + */ +function filterRemainingTrainerMovePool(pool: [id: MoveId, weight: number][], pokemon: Pokemon) { + // Sqrt the weight of any damaging moves with overlapping types. pokemon is about a 0.05 - 0.1 multiplier. + // Other damaging moves 2x weight if 0-1 damaging moves, 0.5x if 2, 0.125x if 3. These weights get 20x if STAB. + // Status moves remain unchanged on weight, pokemon encourages 1-2 + for (const [idx, [moveId, weight]] of pool.entries()) { + let ret: number; + if ( + pokemon.moveset.some( + mo => mo.getMove().category !== MoveCategory.STATUS && mo.getMove().type === allMoves[moveId].type, + ) + ) { + ret = Math.ceil(Math.sqrt(weight)); + } else if (allMoves[moveId].category !== MoveCategory.STATUS) { + ret = Math.ceil( + (weight / Math.max(Math.pow(4, pokemon.moveset.filter(mo => (mo.getMove().power ?? 0) > 1).length) / 8, 0.5)) + * (pokemon.isOfType(allMoves[moveId].type) && !STAB_BLACKLIST.has(moveId) ? 20 : 1), + ); + } else { + ret = weight; + } + pool[idx] = [moveId, ret]; + } +} + +/** + * Fill in the remaining slots in the Pokémon's moveset from the provided pools + * @param pokemon - The Pokémon for which the moveset is being generated + * @param tmPool - The TM move pool + * @param eggMovePool - The egg move pool + * @param tmCount - A holder for the count of moves that have been added to the moveset from TMs + * @param eggMoveCount - A holder for the count of moves that have been added to the moveset from egg moves + * @param baseWeights - The base weights of all moves in the master pool + * @param remainingPool - The remaining move pool to select from + */ +function fillInRemainingMovesetSlots( + pokemon: Pokemon, + tmPool: Map, + eggMovePool: Map, + tmCount: NumberHolder, + eggMoveCount: NumberHolder, + baseWeights: Map, + remainingPool: [id: MoveId, weight: number][], +): void { + const tmCap = getMaxTmCount(pokemon.level); + const eggCap = getMaxEggMoveCount(pokemon.level); + const remainingPoolWeight = new NumberHolder(0); + while (remainingPool.length > pokemon.moveset.length && pokemon.moveset.length < 4) { + const nonLevelMoveCount = tmCount.value + eggMoveCount.value; + remainingPool = filterPool( + baseWeights, + (m: MoveId) => + !pokemon.moveset.some( + mo => + m === mo.moveId || (allMoves[m]?.hasAttr("SacrificialAttr") && mo.getMove()?.hasAttr("SacrificialAttr")), // Only one self-KO move allowed + ) + && (nonLevelMoveCount < tmCap || !tmPool.has(m)) + && (nonLevelMoveCount < eggCap || !eggMovePool.has(m)), + remainingPoolWeight, + ); + if (pokemon.hasTrainer()) { + filterRemainingTrainerMovePool(remainingPool, pokemon); + } + const totalWeight = remainingPool.reduce((v, m) => v + m[1], 0); + let rand = randSeedInt(totalWeight); + let index = 0; + while (rand > remainingPool[index][1]) { + rand -= remainingPool[index++][1]; + } + const selectedMoveId = remainingPool[index][0]; + baseWeights.delete(selectedMoveId); + if (tmPool.has(selectedMoveId)) { + tmCount.value++; + tmPool.delete(selectedMoveId); + } else if (eggMovePool.has(selectedMoveId)) { + eggMoveCount.value++; + eggMovePool.delete(selectedMoveId); + } + pokemon.moveset.push(new PokemonMove(selectedMoveId)); + } +} + +/** + * Debugging function to log computed move weights for a Pokémon + * @param pokemon - The Pokémon for which the move weights were computed + * @param pool - The move pool containing move IDs and their weights + * @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) { + const moveNameToWeightMap = new Map(); + const sortedByValue = Array.from(pool.entries()).sort((a, b) => b[1] - a[1]); + for (const [moveId, weight] of sortedByValue) { + moveNameToWeightMap.set(allMoves[moveId].name, weight); + } + console.log("%cComputed move weights [%s] for %s", "color: blue", note, pokemon.name, moveNameToWeightMap); + } +} + +/** + * Generate a moveset for a given Pokémon based on its level, types, stats, and whether it is wild or a trainer's Pokémon. + * @param pokemon - The Pokémon to generate a moveset for + * @returns A reference to the Pokémon's moveset array + */ +export function generateMoveset(pokemon: Pokemon): void { + pokemon.moveset = []; + // Step 1: Generate the pools from various sources: level up, egg moves, and TMs + const learnPool = getAndWeightLevelMoves(pokemon); + debugMoveWeights(pokemon, learnPool, "Initial Level Moves"); + const hasTrainer = pokemon.hasTrainer(); + const tmPool = new Map(); + const eggMovePool = new Map(); + + if (hasTrainer) { + getAndWeightEggMoves(pokemon, learnPool, eggMovePool); + eggMovePool.size > 0 && debugMoveWeights(pokemon, eggMovePool, "Initial Egg Moves"); + getAndWeightTmMoves(pokemon, learnPool, eggMovePool, tmPool); + tmPool.size > 0 && debugMoveWeights(pokemon, tmPool, "Initial Tm Moves"); + } + + // Now, combine pools into one master pool. + // The pools are kept around so we know where the move was sourced from + const movePool = new Map([...tmPool.entries(), ...eggMovePool.entries(), ...learnPool.entries()]); + + // Step 2: Filter out forbidden moves + const isBoss = pokemon.isBoss(); + filterMovePool(movePool, isBoss, hasTrainer); + + // Step 3: Adjust weights for trainers + if (hasTrainer) { + adjustWeightsForTrainer(movePool); + } + + /** Determine whether this pokemon will instantly tera */ + const willTera = + hasTrainer + && globalScene.currentBattle?.trainer?.config.trainerAI.instantTeras.includes( + // The cast to EnemyPokemon is safe; includes will just return false if the property doesn't exist + (pokemon as EnemyPokemon).initialTeamIndex, + ); + + adjustDamageMoveWeights(movePool, pokemon, willTera); + + /** The higher this is, the greater the impact of weight. At `0` all moves are equal weight. */ + let weightMultiplier = BASE_WEIGHT_MULTIPLIER; + if (isBoss) { + weightMultiplier += BOSS_EXTRA_WEIGHT_MULTIPLIER; + } + + const baseWeights = new Map(movePool); + for (const [moveId, weight] of baseWeights) { + if (weight <= 0) { + baseWeights.delete(moveId); + continue; + } + baseWeights.set(moveId, Math.ceil(Math.pow(weight, weightMultiplier) * 100)); + } + + const tmCount = new NumberHolder(0); + const eggMoveCount = new NumberHolder(0); + + debugMoveWeights(pokemon, baseWeights, "Pre STAB Move"); + + // Step 4: Force a STAB move if possible + forceStabMove(movePool, tmPool, eggMovePool, pokemon, tmCount, eggMoveCount, willTera); + // Note: To force a secondary stab, call this a second time, and pass `false` for the last parameter + // Would also tweak the function to not consider moves already in the moveset + // e.g. forceStabMove(..., false); + + // Step 5: Fill in remaining slots + fillInRemainingMovesetSlots( + pokemon, + tmPool, + eggMovePool, + tmCount, + eggMoveCount, + baseWeights, + filterPool(baseWeights, (m: MoveId) => !pokemon.moveset.some(mo => m[0] === mo.moveId)), + ); +} diff --git a/src/data/balance/moveset-generation.ts b/src/data/balance/moveset-generation.ts new file mode 100644 index 00000000000..d6579eb45e7 --- /dev/null +++ b/src/data/balance/moveset-generation.ts @@ -0,0 +1,209 @@ +/* + * SPDX-Copyright-Text: 2025 Pagefault Games + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { MoveId } from "#enums/move-id"; + + +/** + * # Balance: Moveset Generation Configuration + * + * This module contains configuration constants and functions that control + * the limitations and rules around moveset generation for generated Pokémon. + * + * + * ### Move Weights + * + * The various move weight constants in this module control how likely + * certain categories of moves are to appear in a generated Pokémon's + * moveset. Higher weights make a move more likely to be chosen. + * The constants here specify the *base* weight for a move when first computed. + * These weights are post-processed (and then scaled up such that weights have a larger impact, + * for instance, on boss Pokémon) before being used in the actual moveset generation. + * + * Post Processing of weights includes, but is not limited to: + * - Adjusting weights of status moves + * - Adjusting weights based on the move's power relative to the highest power available + * - Adjusting weights based on the stat the move uses to calculate damage relative to the higher stat + * + * + * All weights go through additional post-processing based on + * their expected power (accuracy * damage * expected number of hits) + * + * @module + */ + + +//#region Constants +/** + * The minimum level for a Pokémon to generate with a move it can only learn + * from a common tier TM + */ +export const COMMON_TIER_TM_LEVEL_REQUIREMENT = 25; +/** + * The minimum level for a Pokémon to generate with a move it can only learn + * from a great tier TM + */ +export const GREAT_TIER_TM_LEVEL_REQUIREMENT = 40; +/** + * The minimum level for a Pokémon to generate with a move it can only learn + * from an ultra tier TM + */ +export const ULTRA_TIER_TM_LEVEL_REQUIREMENT = 55; + +/** Below this level, Pokémon will be unable to generate with any egg moves */ +export const EGG_MOVE_LEVEL_REQUIREMENT = 60; +/** Below this level, Pokémon will be unable to generate with rare egg moves */ +export const RARE_EGG_MOVE_LEVEL_REQUIREMENT = 170; + +// Note: Not exported, only for use with `getMaxTmCount +/** Below this level, Pokémon will be unable to generate with any TMs */ +const ONE_TM_THRESHOLD = 25; +/** Below this level, Pokémon will generate with at most 1 TM */ +const TWO_TM_THRESHOLD = 41; +/** Below this level, Pokémon will generate with at most two TMs */ +const THREE_TM_THRESHOLD = 71; +/** Below this level, Pokémon will generate with at most three TMs */ +const FOUR_TM_THRESHOLD = 101; + +/** Below this level, Pokémon will be unable to generate any egg moves */ +const ONE_EGG_MOVE_THRESHOLD = 80; +/** Below this level, Pokémon will generate with at most 1 egg moves */ +const TWO_EGG_MOVE_THRESHOLD = 121; +/** Below this level, Pokémon will generate with at most 2 egg moves */ +const THREE_EGG_MOVE_THRESHOLD = 161; +/** Above this level, Pokémon will generate with at most 3 egg moves */ +const FOUR_EGG_MOVE_THRESHOLD = 201; + + +/** The weight given to TMs in the common tier during moveset generation */ +export const COMMON_TM_MOVESET_WEIGHT = 12; +/** The weight given to TMs in the great tier during moveset generation */ +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 maximum weight an egg move can ever have + * @remarks + * Egg moves have their weights adjusted based on the maximum weight of the Pokémon's + * level-up moves. Rare Egg moves are always 5/6th of the computed egg move weight. + * Boss pokemon are not allowed to spawn with rare egg moves. + * @see {@linkcode EGG_MOVE_TO_LEVEL_WEIGHT} + */ +export const EGG_MOVE_WEIGHT_MAX = 60; +/** + * The percentage of the Pokémon's highest weighted level move to the weight an + * egg move can generate with + */ +export const EGG_MOVE_TO_LEVEL_WEIGHT = 0.85; +/** The weight given to evolution moves */ +export const EVOLUTION_MOVE_WEIGHT = 70; +/** The weight given to relearn moves */ +export const RELEARN_MOVE_WEIGHT = 60; + +/** The base weight multiplier to use + * + * The higher the number, the more impact weights have on the final move selection. + * i.e. if set to 0, all moves have equal chance of being selected regardless of their weight. + */ +export const BASE_WEIGHT_MULTIPLIER = 1.6; + +/** The additional weight added onto {@linkcode BASE_WEIGHT_MULTIPLIER} for boss Pokémon */ +export const BOSS_EXTRA_WEIGHT_MULTIPLIER = 0.4; + + + +/** + * Set of moves that should be blacklisted from the forced STAB during moveset generation + * + * @remarks + * During moveset generation, trainer pokemon attempt to force their pokemon to generate with STAB + * moves in their movesets. Moves in this list not be considered to be "STAB" moves for this purpose. + * This does *not* prevent them from appearing in the moveset, but they will never + * be selected as a forced STAB move. + */ +export const STAB_BLACKLIST: ReadonlySet = new Set([ + MoveId.BEAT_UP, + MoveId.BELCH, + MoveId.BIDE, + MoveId.COMEUPPANCE, + MoveId.COUNTER, + MoveId.DOOM_DESIRE, + MoveId.DRAGON_RAGE, + MoveId.DREAM_EATER, + MoveId.ENDEAVOR, + MoveId.EXPLOSION, + MoveId.FAKE_OUT, + MoveId.FIRST_IMPRESSION, + MoveId.FISSURE, + MoveId.FLING, + MoveId.FOCUS_PUNCH, + MoveId.FUTURE_SIGHT, + MoveId.GUILLOTINE, + MoveId.HOLD_BACK, + MoveId.HORN_DRILL, + MoveId.LAST_RESORT, + MoveId.METAL_BURST, + MoveId.MIRROR_COAT, + MoveId.MISTY_EXPLOSION, + MoveId.NATURAL_GIFT, + MoveId.NATURES_MADNESS, + MoveId.NIGHT_SHADE, + MoveId.PSYWAVE, + MoveId.RUINATION, + MoveId.SELF_DESTRUCT, + MoveId.SHEER_COLD, + MoveId.SHELL_TRAP, + MoveId.SKY_DROP, + MoveId.SNORE, + MoveId.SONIC_BOOM, + MoveId.SPIT_UP, + MoveId.STEEL_BEAM, + MoveId.STEEL_ROLLER, + MoveId.SUPER_FANG, + MoveId.SYNCHRONOISE, + MoveId.UPPER_HAND, +]); + +//#endregion Constants + +/** + * Get the maximum number of TMs a Pokémon is allowed to learn based on + * its level + * @param level - The level of the Pokémon + * @returns The number of TMs the Pokémon can learn at this level + */ +export function getMaxTmCount(level: number) { + if (level < ONE_TM_THRESHOLD) { + return 0; + } + if (level < TWO_TM_THRESHOLD) { + return 1; + } + if (level < THREE_TM_THRESHOLD) { + return 2; + } + if (level < FOUR_TM_THRESHOLD) { + return 3; + } + return 4; +} + + +export function getMaxEggMoveCount(level: number): number { + if (level < ONE_EGG_MOVE_THRESHOLD) { + return 0; + } + if (level < TWO_EGG_MOVE_THRESHOLD) { + return 1; + } + if (level < THREE_EGG_MOVE_THRESHOLD) { + return 2; + } + if (level < FOUR_EGG_MOVE_THRESHOLD) { + return 3; + } + return 4; +} diff --git a/src/data/trainers/trainer-config.ts b/src/data/trainers/trainer-config.ts index 55362017cee..b5786d1f0a2 100644 --- a/src/data/trainers/trainer-config.ts +++ b/src/data/trainers/trainer-config.ts @@ -3,6 +3,8 @@ import { globalScene } from "#app/global-scene"; import { pokemonEvolutions, pokemonPrevolutions } from "#balance/pokemon-evolutions"; import { signatureSpecies } from "#balance/signature-species"; import { tmSpecies } from "#balance/tms"; +// biome-ignore lint/correctness/noUnusedImports: Used in a tsdoc comment +import type { RARE_EGG_MOVE_LEVEL_REQUIREMENT } from "#data/balance/moveset-generation"; import { modifierTypes } from "#data/data-lists"; import { doubleBattleDialogue } from "#data/double-battle-dialogue"; import { Gender } from "#data/gender"; @@ -41,6 +43,7 @@ import type { TrainerConfigs, TrainerTierPools, } from "#types/trainer-funcs"; +import type { Mutable } from "#types/type-helpers"; import { coerceArray, randSeedInt, randSeedIntRange, randSeedItem } from "#utils/common"; import { getPokemonSpecies } from "#utils/pokemon-utils"; import { toCamelCase, toTitleCase } from "#utils/strings"; @@ -119,6 +122,15 @@ export class TrainerConfig { public hasVoucher = false; public trainerAI: TrainerAI; + /** + * Whether this trainer's Pokémon are allowed to generate with egg moves + * @defaultValue `false` + * + * @see {@linkcode setEggMovesAllowed} + * @see {@linkcode RARE_EGG_MOVE_LEVEL_THRESHOLD} + */ + public readonly allowEggMoves: boolean = false; + public encounterMessages: string[] = []; public victoryMessages: string[] = []; public defeatMessages: string[] = []; @@ -387,8 +399,27 @@ export class TrainerConfig { return this; } - setBoss(): TrainerConfig { + /** + * Allow this trainer's Pokémon to have egg moves when generating their movesets. + * + * @remarks + * It is redundant to call this if {@linkcode setBoss} is also called on the configuration. + * @returns `this` for method chaining + * @see {@linkcode allowEggMoves} + */ + public setEggMovesAllowed(): this { + (this as Mutable).allowEggMoves = true; + return this; + } + + /** + * Set this trainer as a boss trainer + * @returns `this` for method chaining + * @see {@linkcode isBoss} + */ + public setBoss(): TrainerConfig { this.isBoss = true; + (this as Mutable).allowEggMoves = true; return this; } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 9f217cc4d64..46f88abc61c 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1,5 +1,6 @@ import type { Ability, PreAttackModifyDamageAbAttrParams } from "#abilities/ability"; import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs"; +import { generateMoveset } from "#app/ai/ai-moveset-gen"; import type { AnySound, BattleScene } from "#app/battle-scene"; import { PLAYER_PARTY_MAX_SIZE, RARE_CANDY_FRIENDSHIP_CAP } from "#app/constants"; import { timedEventManager } from "#app/global-event-manager"; @@ -18,7 +19,7 @@ import type { LevelMoves } from "#balance/pokemon-level-moves"; import { EVOLVE_MOVE, RELEARN_MOVE } from "#balance/pokemon-level-moves"; import { BASE_HIDDEN_ABILITY_CHANCE, BASE_SHINY_CHANCE, SHINY_EPIC_CHANCE, SHINY_VARIANT_CHANCE } from "#balance/rates"; import { getStarterValueFriendshipCap, speciesStarterCosts } from "#balance/starters"; -import { reverseCompatibleTms, tmPoolTiers, tmSpecies } from "#balance/tms"; +import { reverseCompatibleTms, tmSpecies } from "#balance/tms"; import type { SuppressAbilitiesTag } from "#data/arena-tag"; import { NoCritTag, WeakenMoveScreenTag } from "#data/arena-tag"; import { @@ -81,7 +82,6 @@ import { DexAttr } from "#enums/dex-attr"; import { FieldPosition } from "#enums/field-position"; import { HitResult } from "#enums/hit-result"; import { LearnMoveSituation } from "#enums/learn-move-situation"; -import { ModifierTier } from "#enums/modifier-tier"; import { MoveCategory } from "#enums/move-category"; import { MoveFlags } from "#enums/move-flags"; import { MoveId } from "#enums/move-id"; @@ -1440,6 +1440,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { simulated = true, ignoreHeldItems = false, ): number { + // biome-ignore-start lint/nursery/useMaxParams: test const statVal = new NumberHolder(this.getStat(stat, false)); if (!ignoreHeldItems) { globalScene.applyModifiers(StatBoosterModifier, this.isPlayer(), this, stat, statVal); @@ -1538,6 +1539,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } return Math.floor(ret); + // biome-ignore-end lint/nursery/useMaxParams: test } calculateStats(): void { @@ -2774,7 +2776,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * This causes problems when there are intentional duplicates (i.e. Smeargle with Sketch) */ if (levelMoves) { - this.getUniqueMoves(levelMoves, ret); + Pokemon.getUniqueMoves(levelMoves, ret); } return ret; @@ -2788,7 +2790,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { * @param levelMoves the input array to search for non-duplicates from * @param ret the output array to be pushed into. */ - private getUniqueMoves(levelMoves: LevelMoves, ret: LevelMoves): void { + private static getUniqueMoves(levelMoves: LevelMoves, ret: LevelMoves): void { const uniqueMoves: MoveId[] = []; for (const lm of levelMoves) { if (!uniqueMoves.find(m => m === lm[1])) { @@ -3046,294 +3048,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { /** Generates a semi-random moveset for a Pokemon */ public generateAndPopulateMoveset(): void { - this.moveset = []; - let movePool: [MoveId, number][] = []; - const allLevelMoves = this.getLevelMoves(1, true, true, this.hasTrainer()); - if (!allLevelMoves) { - console.warn("Error encountered trying to generate moveset for:", this.species.name); - return; - } - - for (const levelMove of allLevelMoves) { - if (this.level < levelMove[0]) { - break; - } - let weight = levelMove[0] + 20; - // Evolution Moves - if (levelMove[0] === EVOLVE_MOVE) { - weight = 70; - } - // Assume level 1 moves with 80+ BP are "move reminder" moves and bump their weight. Trainers use actual relearn moves. - if ( - (levelMove[0] === 1 && allMoves[levelMove[1]].power >= 80) - || (levelMove[0] === RELEARN_MOVE && this.hasTrainer()) - ) { - weight = 60; - } - if (!movePool.some(m => m[0] === levelMove[1]) && !allMoves[levelMove[1]].name.endsWith(" (N)")) { - movePool.push([levelMove[1], weight]); - } - } - - if (this.hasTrainer()) { - const tms = Object.keys(tmSpecies); - for (const tm of tms) { - const moveId = Number.parseInt(tm) as MoveId; - let compatible = false; - for (const p of tmSpecies[tm]) { - if (Array.isArray(p)) { - if ( - p[0] === this.species.speciesId - || (this.fusionSpecies - && p[0] === this.fusionSpecies.speciesId - && p.slice(1).indexOf(this.species.forms[this.formIndex]) > -1) - ) { - compatible = true; - break; - } - } else if (p === this.species.speciesId || (this.fusionSpecies && p === this.fusionSpecies.speciesId)) { - compatible = true; - break; - } - } - if (compatible && !movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) { - if (tmPoolTiers[moveId] === ModifierTier.COMMON && this.level >= 15) { - movePool.push([moveId, 24]); - } else if (tmPoolTiers[moveId] === ModifierTier.GREAT && this.level >= 30) { - movePool.push([moveId, 28]); - } else if (tmPoolTiers[moveId] === ModifierTier.ULTRA && this.level >= 50) { - movePool.push([moveId, 34]); - } - } - } - - // No egg moves below level 60 - if (this.level >= 60) { - for (let i = 0; i < 3; i++) { - const moveId = speciesEggMoves[this.species.getRootSpeciesId()][i]; - if (!movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) { - movePool.push([moveId, 60]); - } - } - const moveId = speciesEggMoves[this.species.getRootSpeciesId()][3]; - // No rare egg moves before e4 - if ( - this.level >= 170 - && !movePool.some(m => m[0] === moveId) - && !allMoves[moveId].name.endsWith(" (N)") - && !this.isBoss() - ) { - movePool.push([moveId, 50]); - } - if (this.fusionSpecies) { - for (let i = 0; i < 3; i++) { - const moveId = speciesEggMoves[this.fusionSpecies.getRootSpeciesId()][i]; - if (!movePool.some(m => m[0] === moveId) && !allMoves[moveId].name.endsWith(" (N)")) { - movePool.push([moveId, 60]); - } - } - const moveId = speciesEggMoves[this.fusionSpecies.getRootSpeciesId()][3]; - // No rare egg moves before e4 - if ( - this.level >= 170 - && !movePool.some(m => m[0] === moveId) - && !allMoves[moveId].name.endsWith(" (N)") - && !this.isBoss() - ) { - movePool.push([moveId, 50]); - } - } - } - } - - // Bosses never get self ko moves or Pain Split - if (this.isBoss()) { - movePool = movePool.filter( - m => !allMoves[m[0]].hasAttr("SacrificialAttr") && !allMoves[m[0]].hasAttr("HpSplitAttr"), - ); - } - // No one gets Memento or Final Gambit - movePool = movePool.filter(m => !allMoves[m[0]].hasAttr("SacrificialAttrOnHit")); - if (this.hasTrainer()) { - // Trainers never get OHKO moves - movePool = movePool.filter(m => !allMoves[m[0]].hasAttr("OneHitKOAttr")); - // Half the weight of self KO moves - movePool = movePool.map(m => [m[0], m[1] * (allMoves[m[0]].hasAttr("SacrificialAttr") ? 0.5 : 1)]); - // Trainers get a weight bump to stat buffing moves - movePool = movePool.map(m => [ - m[0], - m[1] * (allMoves[m[0]].getAttrs("StatStageChangeAttr").some(a => a.stages > 1 && a.selfTarget) ? 1.25 : 1), - ]); - // Trainers get a weight decrease to multiturn moves - movePool = movePool.map(m => [ - m[0], - m[1] * (!!allMoves[m[0]].isChargingMove() || !!allMoves[m[0]].hasAttr("RechargeAttr") ? 0.7 : 1), - ]); - } - - // Weight towards higher power moves, by reducing the power of moves below the highest power. - // Caps max power at 90 to avoid something like hyper beam ruining the stats. - // This is a pretty soft weighting factor, although it is scaled with the weight multiplier. - const maxPower = Math.min( - movePool.reduce((v, m) => Math.max(allMoves[m[0]].calculateEffectivePower(), v), 40), - 90, - ); - movePool = movePool.map(m => [ - m[0], - m[1] - * (allMoves[m[0]].category === MoveCategory.STATUS - ? 1 - : Math.max(Math.min(allMoves[m[0]].calculateEffectivePower() / maxPower, 1), 0.5)), - ]); - - // Weight damaging moves against the lower stat. This uses a non-linear relationship. - // If the higher stat is 1 - 1.09x higher, no change. At higher stat ~1.38x lower stat, off-stat moves have half weight. - // One third weight at ~1.58x higher, one quarter weight at ~1.73x higher, one fifth at ~1.87x, and one tenth at ~2.35x higher. - const atk = this.getStat(Stat.ATK); - const spAtk = this.getStat(Stat.SPATK); - const worseCategory: MoveCategory = atk > spAtk ? MoveCategory.SPECIAL : MoveCategory.PHYSICAL; - const statRatio = worseCategory === MoveCategory.PHYSICAL ? atk / spAtk : spAtk / atk; - movePool = movePool.map(m => [ - m[0], - m[1] * (allMoves[m[0]].category === worseCategory ? Math.min(Math.pow(statRatio, 3) * 1.3, 1) : 1), - ]); - - /** The higher this is the more the game weights towards higher level moves. At `0` all moves are equal weight. */ - let weightMultiplier = 1.6; - if (this.isBoss()) { - weightMultiplier += 0.4; - } - const baseWeights: [MoveId, number][] = movePool.map(m => [ - m[0], - Math.ceil(Math.pow(m[1], weightMultiplier) * 100), - ]); - - const STAB_BLACKLIST: ReadonlySet = new Set([ - MoveId.BEAT_UP, - MoveId.BELCH, - MoveId.BIDE, - MoveId.COMEUPPANCE, - MoveId.COUNTER, - MoveId.DOOM_DESIRE, - MoveId.DRAGON_RAGE, - MoveId.DREAM_EATER, - MoveId.ENDEAVOR, - MoveId.EXPLOSION, - MoveId.FAKE_OUT, - MoveId.FIRST_IMPRESSION, - MoveId.FISSURE, - MoveId.FLING, - MoveId.FOCUS_PUNCH, - MoveId.FUTURE_SIGHT, - MoveId.GUILLOTINE, - MoveId.HOLD_BACK, - MoveId.HORN_DRILL, - MoveId.LAST_RESORT, - MoveId.METAL_BURST, - MoveId.MIRROR_COAT, - MoveId.MISTY_EXPLOSION, - MoveId.NATURAL_GIFT, - MoveId.NATURES_MADNESS, - MoveId.NIGHT_SHADE, - MoveId.PSYWAVE, - MoveId.RUINATION, - MoveId.SELF_DESTRUCT, - MoveId.SHEER_COLD, - MoveId.SHELL_TRAP, - MoveId.SKY_DROP, - MoveId.SNORE, - MoveId.SONIC_BOOM, - MoveId.SPIT_UP, - MoveId.STEEL_BEAM, - MoveId.STEEL_ROLLER, - MoveId.SUPER_FANG, - MoveId.SYNCHRONOISE, - MoveId.UPPER_HAND, - ]); - - // All Pokemon force a STAB move first - const stabMovePool = baseWeights.filter( - m => - allMoves[m[0]].category !== MoveCategory.STATUS - && this.isOfType(allMoves[m[0]].type) - && !STAB_BLACKLIST.has(m[0]), - ); - - if (stabMovePool.length > 0) { - const totalWeight = stabMovePool.reduce((v, m) => v + m[1], 0); - let rand = randSeedInt(totalWeight); - let index = 0; - while (rand > stabMovePool[index][1]) { - rand -= stabMovePool[index++][1]; - } - this.moveset.push(new PokemonMove(stabMovePool[index][0])); - } else { - // If there are no damaging STAB moves, just force a random damaging move - const attackMovePool = baseWeights.filter( - m => allMoves[m[0]].category !== MoveCategory.STATUS && !STAB_BLACKLIST.has(m[0]), - ); - if (attackMovePool.length > 0) { - const totalWeight = attackMovePool.reduce((v, m) => v + m[1], 0); - let rand = randSeedInt(totalWeight); - let index = 0; - while (rand > attackMovePool[index][1]) { - rand -= attackMovePool[index++][1]; - } - this.moveset.push(new PokemonMove(attackMovePool[index][0], 0, 0)); - } - } - - while (baseWeights.length > this.moveset.length && this.moveset.length < 4) { - if (this.hasTrainer()) { - // Sqrt the weight of any damaging moves with overlapping types. This is about a 0.05 - 0.1 multiplier. - // Other damaging moves 2x weight if 0-1 damaging moves, 0.5x if 2, 0.125x if 3. These weights get 20x if STAB. - // Status moves remain unchanged on weight, this encourages 1-2 - movePool = baseWeights - .filter( - m => - !this.moveset.some( - mo => - m[0] === mo.moveId - || (allMoves[m[0]].hasAttr("SacrificialAttr") && mo.getMove().hasAttr("SacrificialAttr")), // Only one self-KO move allowed - ), - ) - .map(m => { - let ret: number; - if ( - this.moveset.some( - mo => mo.getMove().category !== MoveCategory.STATUS && mo.getMove().type === allMoves[m[0]].type, - ) - ) { - ret = Math.ceil(Math.sqrt(m[1])); - } else if (allMoves[m[0]].category !== MoveCategory.STATUS) { - ret = Math.ceil( - (m[1] / Math.max(Math.pow(4, this.moveset.filter(mo => (mo.getMove().power ?? 0) > 1).length) / 8, 0.5)) - * (this.isOfType(allMoves[m[0]].type) && !STAB_BLACKLIST.has(m[0]) ? 20 : 1), - ); - } else { - ret = m[1]; - } - return [m[0], ret]; - }); - } else { - // Non-trainer pokemon just use normal weights - movePool = baseWeights.filter( - m => - !this.moveset.some( - mo => - m[0] === mo.moveId - || (allMoves[m[0]].hasAttr("SacrificialAttr") && mo.getMove().hasAttr("SacrificialAttr")), // Only one self-KO move allowed - ), - ); - } - const totalWeight = movePool.reduce((v, m) => v + m[1], 0); - let rand = randSeedInt(totalWeight); - let index = 0; - while (rand > movePool[index][1]) { - rand -= movePool[index++][1]; - } - this.moveset.push(new PokemonMove(movePool[index][0])); - } + generateMoveset(this); // Trigger FormChange, except for enemy Pokemon during Mystery Encounters, to avoid crashes if ( From 793dea002401887678f8c767d97efc1365f10c4f Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Sun, 14 Sep 2025 22:35:12 -0500 Subject: [PATCH 2/8] [Bug] [Docs] Fix `@module` tags (#6557) Fix `@module` tags --- src/constants/colors.ts | 3 +- src/data/arena-tag.ts | 71 ++++++++++++------------ src/data/balance/moveset-generation.ts | 4 +- src/data/battler-tags.ts | 77 +++++++++++++------------- src/phase-manager.ts | 17 +++--- test/test-utils/setup/test-end-log.ts | 2 +- 6 files changed, 85 insertions(+), 89 deletions(-) diff --git a/src/constants/colors.ts b/src/constants/colors.ts index 717c5fa5f0d..a2400ef5f90 100644 --- a/src/constants/colors.ts +++ b/src/constants/colors.ts @@ -1,7 +1,8 @@ /** - * @module + * * A big file storing colors used in logging. * Minified by Terser during production builds, so has no overhead. + * @module */ // Colors used in prod diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index d3098314beb..e59d7173538 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -1,39 +1,4 @@ -/** biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports */ -import type { BattlerTag } from "#app/data/battler-tags"; -/** biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports */ - -import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs"; -import { globalScene } from "#app/global-scene"; -import { getPokemonNameWithAffix } from "#app/messages"; -import { CommonBattleAnim } from "#data/battle-anims"; -import { allMoves } from "#data/data-lists"; -import { AbilityId } from "#enums/ability-id"; -import { ArenaTagSide } from "#enums/arena-tag-side"; -import { ArenaTagType } from "#enums/arena-tag-type"; -import { BattlerTagType } from "#enums/battler-tag-type"; -import { HitResult } from "#enums/hit-result"; -import { CommonAnim } from "#enums/move-anims-common"; -import { MoveCategory } from "#enums/move-category"; -import { MoveId } from "#enums/move-id"; -import { MoveTarget } from "#enums/move-target"; -import { PokemonType } from "#enums/pokemon-type"; -import { Stat } from "#enums/stat"; -import { StatusEffect } from "#enums/status-effect"; -import type { Arena } from "#field/arena"; -import type { Pokemon } from "#field/pokemon"; -import type { - ArenaScreenTagType, - ArenaTagData, - EntryHazardTagType, - RoomArenaTagType, - SerializableArenaTagType, -} from "#types/arena-tags"; -import type { Mutable } from "#types/type-helpers"; -import { BooleanHolder, type NumberHolder, toDmgValue } from "#utils/common"; -import i18next from "i18next"; - /** - * @module * ArenaTags are are meant for effects that are tied to the arena (as opposed to a specific pokemon). * Examples include (but are not limited to) * - Cross-turn effects that persist even if the user/target switches out, such as Happy Hour @@ -75,8 +40,42 @@ import i18next from "i18next"; * } * ``` * Notes - * - If the class has any subclasses, then the second form of `loadTag` *must* be used. + * - If the class has any subclasses, then the second form of `loadTag` *must* be used.\ + * @module */ +// biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports +import type { BattlerTag } from "#app/data/battler-tags"; +// biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports + +import { applyAbAttrs, applyOnGainAbAttrs, applyOnLoseAbAttrs } from "#abilities/apply-ab-attrs"; +import { globalScene } from "#app/global-scene"; +import { getPokemonNameWithAffix } from "#app/messages"; +import { CommonBattleAnim } from "#data/battle-anims"; +import { allMoves } from "#data/data-lists"; +import { AbilityId } from "#enums/ability-id"; +import { ArenaTagSide } from "#enums/arena-tag-side"; +import { ArenaTagType } from "#enums/arena-tag-type"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { HitResult } from "#enums/hit-result"; +import { CommonAnim } from "#enums/move-anims-common"; +import { MoveCategory } from "#enums/move-category"; +import { MoveId } from "#enums/move-id"; +import { MoveTarget } from "#enums/move-target"; +import { PokemonType } from "#enums/pokemon-type"; +import { Stat } from "#enums/stat"; +import { StatusEffect } from "#enums/status-effect"; +import type { Arena } from "#field/arena"; +import type { Pokemon } from "#field/pokemon"; +import type { + ArenaScreenTagType, + ArenaTagData, + EntryHazardTagType, + RoomArenaTagType, + SerializableArenaTagType, +} from "#types/arena-tags"; +import type { Mutable } from "#types/type-helpers"; +import { BooleanHolder, type NumberHolder, toDmgValue } from "#utils/common"; +import i18next from "i18next"; /** Interface containing the serializable fields of ArenaTagData. */ interface BaseArenaTag { diff --git a/src/data/balance/moveset-generation.ts b/src/data/balance/moveset-generation.ts index d6579eb45e7..91423f80f9a 100644 --- a/src/data/balance/moveset-generation.ts +++ b/src/data/balance/moveset-generation.ts @@ -3,9 +3,6 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { MoveId } from "#enums/move-id"; - - /** * # Balance: Moveset Generation Configuration * @@ -33,6 +30,7 @@ import { MoveId } from "#enums/move-id"; * * @module */ +import { MoveId } from "#enums/move-id"; //#region Constants diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 6490a6086c4..dc4a5383e24 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1,3 +1,41 @@ +/** + * BattlerTags are used to represent semi-persistent effects that can be attached to a Pokemon. + * Note that before serialization, a new tag object is created, and then `loadTag` is called on the + * tag with the object that was serialized. + * + * This means it is straightforward to avoid serializing fields. + * Fields that are not set in the constructor and not set in `loadTag` will thus not be serialized. + * + * Any battler tag that can persist across sessions must extend SerializableBattlerTag in its class definition signature. + * Only tags that persist across waves (meaning their effect can last >1 turn) should be considered + * serializable. + * + * Serializable battler tags have strict requirements for their fields. + * Properties that are not necessary to reconstruct the tag must not be serialized. This can be avoided + * by using a private property. If access to the property is needed outside of the class, then + * a getter (and potentially, a setter) should be used instead. + * + * If a property that is intended to be private must be serialized, then it should instead + * be declared as a public readonly propety. Then, in the `loadTag` method (or any method inside the class that needs to adjust the property) + * use `(this as Mutable).propertyName = value;` + * These rules ensure that Typescript is aware of the shape of the serialized version of the class. + * + * If any new serializable fields *are* added, then the class *must* override the + * `loadTag` method to set the new fields. Its signature *must* match the example below: + * ``` + * class ExampleTag extends SerializableBattlerTag { + * // Example, if we add 2 new fields that should be serialized: + * public a: string; + * public b: number; + * // Then we must also define a loadTag method with one of the following signatures + * public override loadTag(source: BaseBattlerTag & Pick(source: BaseBattlerTag & Pick): void; + * } + * ``` + * Notes + * - If the class has any subclasses, then the second form of `loadTag` *must* be used. + * @module + */ import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; @@ -52,45 +90,6 @@ import type { Mutable } from "#types/type-helpers"; import { BooleanHolder, coerceArray, getFrameMs, NumberHolder, toDmgValue } from "#utils/common"; import { toCamelCase } from "#utils/strings"; -/** - * @module - * BattlerTags are used to represent semi-persistent effects that can be attached to a Pokemon. - * Note that before serialization, a new tag object is created, and then `loadTag` is called on the - * tag with the object that was serialized. - * - * This means it is straightforward to avoid serializing fields. - * Fields that are not set in the constructor and not set in `loadTag` will thus not be serialized. - * - * Any battler tag that can persist across sessions must extend SerializableBattlerTag in its class definition signature. - * Only tags that persist across waves (meaning their effect can last >1 turn) should be considered - * serializable. - * - * Serializable battler tags have strict requirements for their fields. - * Properties that are not necessary to reconstruct the tag must not be serialized. This can be avoided - * by using a private property. If access to the property is needed outside of the class, then - * a getter (and potentially, a setter) should be used instead. - * - * If a property that is intended to be private must be serialized, then it should instead - * be declared as a public readonly propety. Then, in the `loadTag` method (or any method inside the class that needs to adjust the property) - * use `(this as Mutable).propertyName = value;` - * These rules ensure that Typescript is aware of the shape of the serialized version of the class. - * - * If any new serializable fields *are* added, then the class *must* override the - * `loadTag` method to set the new fields. Its signature *must* match the example below: - * ``` - * class ExampleTag extends SerializableBattlerTag { - * // Example, if we add 2 new fields that should be serialized: - * public a: string; - * public b: number; - * // Then we must also define a loadTag method with one of the following signatures - * public override loadTag(source: BaseBattlerTag & Pick(source: BaseBattlerTag & Pick): void; - * } - * ``` - * Notes - * - If the class has any subclasses, then the second form of `loadTag` *must* be used. - */ - /** Interface containing the serializable fields of BattlerTag */ interface BaseBattlerTag { /** The tag's remaining duration */ diff --git a/src/phase-manager.ts b/src/phase-manager.ts index 4bb7e0a4b37..253c6d8314d 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -1,3 +1,11 @@ +/** + * Manager for phases used by battle scene. + * + * @remarks + * **This file must not be imported or used directly.** + * The manager is exclusively used by the Battle Scene and is NOT intended for external use. + * @module + */ import { PHASE_START_COLOR } from "#app/constants/colors"; import { globalScene } from "#app/global-scene"; import type { Phase } from "#app/phase"; @@ -103,15 +111,6 @@ import { WeatherEffectPhase } from "#phases/weather-effect-phase"; import type { PhaseMap, PhaseString } from "#types/phase-types"; import { type Constructor, coerceArray } from "#utils/common"; -/** - * @module - * Manager for phases used by battle scene. - * - * @remarks - * **This file must not be imported or used directly.** - * The manager is exclusively used by the Battle Scene and is NOT intended for external use. - */ - /** * Object that holds all of the phase constructors. * This is used to create new phases dynamically using the `newPhase` method in the `PhaseManager`. diff --git a/test/test-utils/setup/test-end-log.ts b/test/test-utils/setup/test-end-log.ts index 9814ba8a45c..fbcb9480330 100644 --- a/test/test-utils/setup/test-end-log.ts +++ b/test/test-utils/setup/test-end-log.ts @@ -5,10 +5,10 @@ import chalk from "chalk"; import type { RunnerTask, RunnerTaskResult, RunnerTestCase } from "vitest"; /** - * @module * Code to add markers to the beginning and end of tests. * Intended for use with {@linkcode CustomDefaultReporter}, and placed inside test hooks * (rather than as part of the reporter) to ensure Vitest waits for the log messages to be printed. + * @module */ /** A long string of "="s to partition off each test from one another. */ From de94e738fbc5eed77b104152632eaf51e9b8ee86 Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Sun, 14 Sep 2025 20:45:01 -0700 Subject: [PATCH 3/8] [Docs] Add blank space to prevent incorrect comment attachment Biome will "attach" comments to imports if there is no space between them when it sorts imports (this allows suppression comments to work) --- src/data/arena-tag.ts | 3 ++- src/data/balance/moveset-generation.ts | 1 + src/data/battler-tags.ts | 1 + src/phase-manager.ts | 1 + test/test-utils/setup/test-end-log.ts | 12 ++++++------ 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index e59d7173538..ff939194bcd 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -40,9 +40,10 @@ * } * ``` * Notes - * - If the class has any subclasses, then the second form of `loadTag` *must* be used.\ + * - If the class has any subclasses, then the second form of `loadTag` *must* be used. * @module */ + // biome-ignore-start lint/correctness/noUnusedImports: TSDoc imports import type { BattlerTag } from "#app/data/battler-tags"; // biome-ignore-end lint/correctness/noUnusedImports: TSDoc imports diff --git a/src/data/balance/moveset-generation.ts b/src/data/balance/moveset-generation.ts index 91423f80f9a..f9c2a03f4a9 100644 --- a/src/data/balance/moveset-generation.ts +++ b/src/data/balance/moveset-generation.ts @@ -30,6 +30,7 @@ * * @module */ + import { MoveId } from "#enums/move-id"; diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index dc4a5383e24..80a30516903 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -36,6 +36,7 @@ * - If the class has any subclasses, then the second form of `loadTag` *must* be used. * @module */ + import { applyAbAttrs } from "#abilities/apply-ab-attrs"; import { globalScene } from "#app/global-scene"; import { getPokemonNameWithAffix } from "#app/messages"; diff --git a/src/phase-manager.ts b/src/phase-manager.ts index 253c6d8314d..3fbf68de60d 100644 --- a/src/phase-manager.ts +++ b/src/phase-manager.ts @@ -6,6 +6,7 @@ * The manager is exclusively used by the Battle Scene and is NOT intended for external use. * @module */ + import { PHASE_START_COLOR } from "#app/constants/colors"; import { globalScene } from "#app/global-scene"; import type { Phase } from "#app/phase"; diff --git a/test/test-utils/setup/test-end-log.ts b/test/test-utils/setup/test-end-log.ts index fbcb9480330..5be8299b124 100644 --- a/test/test-utils/setup/test-end-log.ts +++ b/test/test-utils/setup/test-end-log.ts @@ -1,9 +1,3 @@ -// biome-ignore lint/correctness/noUnusedImports: TSDoc -import type CustomDefaultReporter from "#test/test-utils/reporters/custom-default-reporter"; -import { basename, join, relative } from "path"; -import chalk from "chalk"; -import type { RunnerTask, RunnerTaskResult, RunnerTestCase } from "vitest"; - /** * Code to add markers to the beginning and end of tests. * Intended for use with {@linkcode CustomDefaultReporter}, and placed inside test hooks @@ -11,6 +5,12 @@ import type { RunnerTask, RunnerTaskResult, RunnerTestCase } from "vitest"; * @module */ +// biome-ignore lint/correctness/noUnusedImports: TSDoc +import type CustomDefaultReporter from "#test/test-utils/reporters/custom-default-reporter"; +import { basename, join, relative } from "path"; +import chalk from "chalk"; +import type { RunnerTask, RunnerTaskResult, RunnerTestCase } from "vitest"; + /** A long string of "="s to partition off each test from one another. */ const TEST_END_BARRIER = chalk.bold.hex("#ff7c7cff")("=================="); From b7cee4b3131211587ec8b67f085b456c5fbc0892 Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Mon, 15 Sep 2025 00:25:25 -0700 Subject: [PATCH 4/8] [Misc] Remove leftover temporary comments in `pokemon.ts` --- src/field/pokemon.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 46f88abc61c..4b8a39ee759 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -1440,7 +1440,6 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { simulated = true, ignoreHeldItems = false, ): number { - // biome-ignore-start lint/nursery/useMaxParams: test const statVal = new NumberHolder(this.getStat(stat, false)); if (!ignoreHeldItems) { globalScene.applyModifiers(StatBoosterModifier, this.isPlayer(), this, stat, statVal); @@ -1539,7 +1538,6 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { } return Math.floor(ret); - // biome-ignore-end lint/nursery/useMaxParams: test } calculateStats(): void { From 8013093513fd272b6f8aa5d76329c682a098813d Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Mon, 15 Sep 2025 14:04:13 -0500 Subject: [PATCH 5/8] [Bug] Secondary fusions with gender evo condition can now evolve (#6510) --- src/data/balance/pokemon-evolutions.ts | 6 +++--- src/field/pokemon.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/data/balance/pokemon-evolutions.ts b/src/data/balance/pokemon-evolutions.ts index c868a49dbfa..0c2fa4e78fa 100644 --- a/src/data/balance/pokemon-evolutions.ts +++ b/src/data/balance/pokemon-evolutions.ts @@ -166,7 +166,7 @@ export class SpeciesEvolutionCondition { return this.desc; } - public conditionsFulfilled(pokemon: Pokemon): boolean { + public conditionsFulfilled(pokemon: Pokemon, forFusion = false): boolean { console.log(this.data); return this.data.every(cond => { switch (cond.key) { @@ -186,7 +186,7 @@ export class SpeciesEvolutionCondition { m.getStackCount() + pokemon.getPersistentTreasureCount() >= cond.value ); case EvoCondKey.GENDER: - return pokemon.gender === cond.gender; + return cond.gender === (forFusion ? pokemon.fusionGender : pokemon.gender); case EvoCondKey.SHEDINJA: // Shedinja cannot be evolved into directly return false; case EvoCondKey.BIOME: @@ -293,7 +293,7 @@ export class SpeciesFormEvolution { pokemon.level >= this.level && // Check form key, using the fusion's form key if we're checking the fusion (this.preFormKey == null || (forFusion ? pokemon.getFusionFormKey() : pokemon.getFormKey()) === this.preFormKey) && - (this.condition == null || this.condition.conditionsFulfilled(pokemon)) && + (this.condition == null || this.condition.conditionsFulfilled(pokemon, forFusion)) && ((item ?? EvolutionItem.NONE) === (this.item ?? EvolutionItem.NONE)) ); } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 4b8a39ee759..d48f4ae8ad2 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2635,7 +2635,7 @@ export abstract class Pokemon extends Phaser.GameObjects.Container { e => new FusionSpeciesFormEvolution(this.species.speciesId, e), ); for (const fe of fusionEvolutions) { - if (fe.validate(this)) { + if (fe.validate(this, true)) { return fe; } } From 65bb58a635c5e32eb852f5ab2db438e97b0ef907 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Mon, 15 Sep 2025 16:51:28 -0400 Subject: [PATCH 6/8] [Dev] Fix + rename `test:create` boilerplate file (#6560) * Rename `default.ts` to `default.boilerplate.ts` * Update `test:create` script to look in the correct location * Update `biome.jsonc` to remove explicit boilerplate folder check * Apply Biome --- biome.jsonc | 11 ++--------- .../{default.ts => default.boilerplate.ts} | 2 +- scripts/create-test/create-test.js | 4 ++-- 3 files changed, 5 insertions(+), 12 deletions(-) rename scripts/create-test/boilerplates/{default.ts => default.boilerplate.ts} (94%) diff --git a/biome.jsonc b/biome.jsonc index 9e87518e636..2433ba52010 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -254,16 +254,9 @@ }, // Overrides to prevent unused import removal inside `overrides.ts`, enums & `.d.ts` files (for TSDoc linkcodes), - // as well as inside script boilerplate files. + // as well as inside script boilerplate files (whose imports will _presumably_ be used in the generated file). { - // TODO: Rename existing boilerplates in the folder and remove this last alias - "includes": [ - "**/src/overrides.ts", - "**/src/enums/**/*", - "**/*.d.ts", - "scripts/**/*.boilerplate.ts", - "**/boilerplates/*.ts" - ], + "includes": ["**/src/overrides.ts", "**/src/enums/**/*", "**/*.d.ts", "scripts/**/*.boilerplate.ts"], "linter": { "rules": { "correctness": { diff --git a/scripts/create-test/boilerplates/default.ts b/scripts/create-test/boilerplates/default.boilerplate.ts similarity index 94% rename from scripts/create-test/boilerplates/default.ts rename to scripts/create-test/boilerplates/default.boilerplate.ts index e644e740594..7b633cf8276 100644 --- a/scripts/create-test/boilerplates/default.ts +++ b/scripts/create-test/boilerplates/default.boilerplate.ts @@ -47,6 +47,6 @@ describe("{{description}}", () => { await game.toEndOfTurn(); expect(feebas).toHaveUsedMove({ move: MoveId.SPLASH, result: MoveResult.SUCCESS }); - expect(game.textInterceptor.logs).toContain(i18next.t("moveTriggers:splash")); + expect(game).toHaveShownMessage(i18next.t("moveTriggers:splash")); }); }); diff --git a/scripts/create-test/create-test.js b/scripts/create-test/create-test.js index 5e395783da7..df065657346 100644 --- a/scripts/create-test/create-test.js +++ b/scripts/create-test/create-test.js @@ -102,9 +102,9 @@ async function promptFileName(selectedType) { function getBoilerplatePath(choiceType) { switch (choiceType) { // case "Reward": - // return path.join(__dirname, "boilerplates/reward.ts"); + // return path.join(__dirname, "boilerplates/reward.boilerplate.ts"); default: - return path.join(__dirname, "boilerplates/default.ts"); + return path.join(__dirname, "boilerplates/default.boilerplate.ts"); } } From 6ce59ecbd983bccbf24ca3c7f4c2cbf42d78a41d Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:49:18 -0500 Subject: [PATCH 7/8] [Bug] Cookies being fetched improperly --- src/utils/cookies.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/cookies.ts b/src/utils/cookies.ts index e82895d1fac..49c47437241 100644 --- a/src/utils/cookies.ts +++ b/src/utils/cookies.ts @@ -25,9 +25,9 @@ export function getCookie(cName: string): string { const ca = document.cookie.split(";"); // Check all cookies in the document and see if any of them match, grabbing the first one whose value lines up for (const c of ca) { - const cTrimmed = c.trim(); + const cTrimmed = c.trimStart(); if (cTrimmed.startsWith(name)) { - return c.slice(name.length, c.length); + return c.substring(name.length, c.length); } } return ""; From 85c38dfdbe1a6cf6aa26862ec7952ab76f2d7ab0 Mon Sep 17 00:00:00 2001 From: Sirz Benjie <142067137+SirzBenjie@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:55:45 -0500 Subject: [PATCH 8/8] [Bug] Cookies being fetched improperly v2 --- src/utils/cookies.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/utils/cookies.ts b/src/utils/cookies.ts index 49c47437241..e16d9d78556 100644 --- a/src/utils/cookies.ts +++ b/src/utils/cookies.ts @@ -23,10 +23,12 @@ export function getCookie(cName: string): string { } const name = `${cName}=`; const ca = document.cookie.split(";"); - // Check all cookies in the document and see if any of them match, grabbing the first one whose value lines up - for (const c of ca) { - const cTrimmed = c.trimStart(); - if (cTrimmed.startsWith(name)) { + for (let c of ca) { + // ⚠️ DO NOT REPLACE THIS WITH C = C.TRIM() - IT BREAKS IN NON-CHROMIUM BROWSERS ⚠️ + while (c.charAt(0) === " ") { + c = c.substring(1); + } + if (c.indexOf(name) === 0) { return c.substring(name.length, c.length); } }