pokemon-showdown/data/cg-teams.ts
pyuk-bot 4262053d2f
CGT: Update item generation (#9340)
* CGT: Update item generation

* Fix indent
2023-03-29 20:55:28 -07:00

669 lines
23 KiB
TypeScript

/**
* Computer-Generated Teams
*
* Generates teams based on heuristics, most of which carry over across generations.
* Teams generated will not always be competitively great, but they will have variety
* and be fun to play (i.e., tries to avoid awful sets).
*
* The [Gen 9] Computer-Generated Teams format is personally maintained by Annika,
* and is not part of any official Smogon or PS format selection. If you enjoy playing
* with teams you didn't make yourself, you may want to check out Random Battles, Battle Factory,
* and/or the sample teams for usage-based formats like OU.
*
* The core of the generator is the weightedRandomPick function, which chooses from an array
* of options based on a weight associated with each option. This way, better/stronger/more useful options
* are more likely to be chosen, but there's still an opportunity for weaker, more situational,
* or higher-risk/higher-reward options to be chosen. However, for moves, the 'worst' moves are excluded
* altogether, both to reduce the likelihood of a bad moveset and improve generator performance.
*
* Certain less-relevant aspects of the set are not randomized at all, such as:
* - IVs (all 31s, with 0 Attack IV if the Pokémon has no Physical moves in case of Confusion)
* - EVs (84 per stat, for +21 to each)
* - Nature (always Quirky, which has no effect)
* - Happiness (there are no Happiness-based moves in Gen IX)
*
* Currently, leveling is based on a Pokémon's position within Smogon's usage-based tiers,
* but an automatic leveling system is planned for the future. This would involve storing win and loss
* data by Pokémon species in a database, and increasing and decreasing the levels of Pokémon species
* each day based on their win/loss ratio. For example, if 60% of matches with a Pokémon species are wins,
* the species is probably overleveled!
*
* Other aspects of the team generator that may be worth implementing in the future include:
* - Explicit support for weather-oriented teams (boosting moves and typings that synergize with that weather)
* - Tracking type coverage to make it more likely that a moveset can hit every type
*/
import {Dex, PRNG, SQL} from '../sim';
import {
ABILITY_MOVE_BONUSES,
ABILITY_MOVE_TYPE_BONUSES,
HARDCODED_MOVE_WEIGHTS,
MOVE_PAIRINGS,
SPEED_BASED_MOVES,
WEIGHT_BASED_MOVES,
} from './cg-team-data';
interface TeamStats {
hazardSetters: {[moveid: string]: number};
typeWeaknesses: {[type: string]: number};
}
// We put a limit on the number of Pokémon on a team that can be weak to a given type.
const MAX_WEAK_TO_SAME_TYPE = 3;
const levelOverride: {[speciesID: string]: number} = {};
export let levelUpdateInterval: NodeJS.Timeout | null = null;
async function updateLevels(database: SQL.DatabaseManager) {
const updateSpecies = await database.prepare(
'UPDATE gen9computergeneratedteams SET wins = 0, losses = 0, level = ? WHERE species_id = ?'
);
const updateHistory = await database.prepare(
`INSERT INTO gen9_historical_levels (level, species_id, timestamp) VALUES (?, ?, ${Date.now()})`
);
const data = await database.all('SELECT species_id, wins, losses, level FROM gen9computergeneratedteams');
for (let {species_id, wins, losses, level} of data) {
const total = wins + losses;
if (total > 10) {
if (wins / total >= 0.55) level--;
if (wins / total <= 0.45) level++;
level = Math.max(1, Math.min(100, level));
await updateSpecies?.run([level, species_id]);
await updateHistory?.run([level, species_id]);
}
levelOverride[species_id] = level;
}
}
if (global.Config && Config.usesqlite && Config.usesqliteleveling) {
const database = SQL(module, {file: './databases/battlestats.db'});
// update every 2 hours
void updateLevels(database);
levelUpdateInterval = setInterval(() => void updateLevels(database), 1000 * 60 * 60 * 2);
}
export default class TeamGenerator {
dex: ModdedDex;
format: Format;
teamSize: number;
forceLevel?: number;
prng: PRNG;
itemPool: Item[];
constructor(format: Format | string, seed: PRNG | PRNGSeed | null) {
this.dex = Dex.forFormat(format);
this.format = Dex.formats.get(format);
this.teamSize = this.format.ruleTable?.maxTeamSize || 6;
this.prng = seed instanceof PRNG ? seed : new PRNG(seed);
this.itemPool = this.dex.items.all().filter(i => i.exists && i.isNonstandard !== 'Past' && !i.isPokeball);
const rules = Dex.formats.getRuleTable(this.format);
if (rules.adjustLevel) this.forceLevel = rules.adjustLevel;
}
getTeam(): PokemonSet[] {
let speciesPool = this.dex.species.all().filter(s => {
if (!s.exists) return false;
if (s.isNonstandard || s.isNonstandard === 'Unobtainable') return false;
if (s.nfe) return false;
if (s.battleOnly && !s.requiredItems?.length) return false;
return true;
});
const teamStats: TeamStats = {
hazardSetters: {},
typeWeaknesses: {},
};
const team: PokemonSet[] = [];
while (team.length < this.teamSize && speciesPool.length) {
const species = this.prng.sample(speciesPool);
const haveRoomToReject = speciesPool.length >= (this.teamSize - team.length);
const isGoodFit = this.speciesIsGoodFit(species, teamStats);
if (haveRoomToReject && !isGoodFit) continue;
speciesPool = speciesPool.filter(s => s.baseSpecies !== species.baseSpecies);
team.push(this.makeSet(species, teamStats));
}
return team;
}
protected makeSet(species: Species, teamStats: TeamStats): PokemonSet {
const abilityPool = Object.values(species.abilities);
const abilityWeights = abilityPool.map(a => this.getAbilityWeight(this.dex.abilities.get(a)));
const ability = this.weightedRandomPick(abilityPool, abilityWeights);
const moves: Move[] = [];
let learnset = this.dex.species.getLearnset(species.id);
let movePool: string[] = [];
let learnsetSpecies = species;
if (!learnset || species.id === 'gastrodoneast') {
learnsetSpecies = this.dex.species.get(species.baseSpecies);
learnset = this.dex.species.getLearnset(learnsetSpecies.id);
}
if (learnset) {
movePool = Object.keys(learnset).filter(
moveid => learnset![moveid].find(learned => learned.startsWith('9'))
);
}
if (learnset && learnsetSpecies === species && species.changesFrom) {
const changesFrom = this.dex.species.get(species.changesFrom);
learnset = this.dex.species.getLearnset(changesFrom.id);
for (const moveid in learnset) {
if (!movePool.includes(moveid) && learnset[moveid].some(source => source.startsWith('9'))) {
movePool.push(moveid);
}
}
}
const evoRegion = learnsetSpecies.evoRegion;
while (learnsetSpecies.prevo) {
learnsetSpecies = this.dex.species.get(learnsetSpecies.prevo);
for (const moveid in learnset) {
if (!movePool.includes(moveid) &&
learnset[moveid].some(source => source.startsWith('9') && !evoRegion)) {
movePool.push(moveid);
}
}
}
if (!movePool.length) throw new Error(`No moves for ${species.id}`);
// Consider either the top 15 moves or top 30% of moves, whichever is greater.
const numberOfMovesToConsider = Math.min(movePool.length, Math.max(15, Math.trunc(movePool.length * 0.3)));
let movePoolIsTrimmed = false;
while (moves.length < 4 && movePool.length) {
let weights;
if (!movePoolIsTrimmed) {
const interimMovePool = [];
for (const move of movePool) {
const weight = this.getMoveWeight(this.dex.moves.get(move), teamStats, species, moves, ability);
interimMovePool.push({move, weight});
}
interimMovePool.sort((a, b) => b.weight - a.weight);
movePool = [];
weights = [];
for (let i = 0; i < numberOfMovesToConsider; i++) {
movePool.push(interimMovePool[i].move);
weights.push(interimMovePool[i].weight);
}
movePoolIsTrimmed = true;
} else {
weights = movePool.map(m => this.getMoveWeight(this.dex.moves.get(m), teamStats, species, moves, ability));
}
const moveID = this.weightedRandomPick(movePool, weights, {remove: true});
// add paired moves, like RestTalk
const pairedMove = MOVE_PAIRINGS[moveID];
const alreadyHavePairedMove = moves.some(m => m.id === pairedMove);
if (
moves.length < 3 &&
pairedMove &&
!alreadyHavePairedMove &&
// We don't check movePool because sometimes paired moves are bad.
this.dex.species.getLearnset(species.id)?.[pairedMove]
) {
moves.push(this.dex.moves.get(pairedMove));
movePool.splice(movePool.indexOf(pairedMove), 1);
}
moves.push(this.dex.moves.get(moveID));
}
let item = '';
if (species.requiredItem) {
item = species.requiredItem;
} else if (species.requiredItems) {
item = this.prng.sample(species.requiredItems.filter(i => !this.dex.items.get(i).isNonstandard));
} else if (moves.every(m => m.id !== 'acrobatics')) { // Don't assign an item if the set includes Acrobatics...
const weights = [];
const items = [];
for (const i of this.itemPool) {
// If the species has a special item, we should use it.
if (i.itemUser?.includes(species.name)) {
item = i.name;
break;
}
const weight = this.getItemWeight(i, teamStats, species, moves, ability);
if (weight !== 0) {
weights.push(weight);
items.push(i.name);
}
}
if (!item) item = this.weightedRandomPick(items, weights);
} else if (['Quark Drive', 'Protosynthesis'].includes(ability)) {
// ...unless the Pokemon can use Booster Energy
item = 'Booster Energy';
}
const ivs: PokemonSet['ivs'] = {
hp: 31,
atk: moves.some(move => this.dex.moves.get(move).category === 'Physical') ? 31 : 0,
def: 31,
spa: 31,
spd: 31,
spe: 31,
};
const level = this.forceLevel || TeamGenerator.getLevel(species);
// For Tera Type, we just pick a random type if it's got Tera Blast or no attacking moves,
// and the type of one of its attacking moves otherwise (so it can take advantage of the boosts).
let teraType;
const nonStatusMoves = moves.filter(move => this.dex.moves.get(move).category !== 'Status');
if (!moves.some(m => m.id === 'terablast') && nonStatusMoves.length) {
teraType = this.prng.sample(nonStatusMoves.map(move => this.dex.moves.get(move).type));
} else {
teraType = this.prng.sample([...this.dex.types.all()]).name;
}
return {
name: species.name,
species: species.name,
item,
ability,
moves: moves.map(m => m.name),
nature: 'Quirky',
gender: species.gender,
evs: {hp: 84, atk: 84, def: 84, spa: 84, spd: 84, spe: 84},
ivs,
level,
teraType,
shiny: this.prng.randomChance(1, 1024),
happiness: 255,
};
}
/**
* @returns true if the Pokémon is a good fit for the team so far, and no otherwise
*/
protected speciesIsGoodFit(species: Species, stats: TeamStats): boolean {
// type check
for (const type of this.dex.types.all()) {
const effectiveness = this.dex.getEffectiveness(type.name, species.types);
if (effectiveness === 1) { // WEAKNESS!
if (stats.typeWeaknesses[type.name] === undefined) {
stats.typeWeaknesses[type.name] = 0;
}
if (stats.typeWeaknesses[type.name] >= MAX_WEAK_TO_SAME_TYPE) {
// too many weaknesses to this type
return false;
}
}
}
// species passes; increment counters
for (const type of this.dex.types.all()) {
const effectiveness = this.dex.getEffectiveness(type.name, species.types);
if (effectiveness === 1) {
stats.typeWeaknesses[type.name]++;
}
}
return true;
}
/**
* @returns A weighting for the Pokémon's ability.
*/
protected getAbilityWeight(ability: Ability): number {
return ability.rating + 1; // Some ability ratings are -1
}
/**
* @returns A weight for a given move on a given Pokémon.
*/
protected getMoveWeight(
move: Move,
teamStats: TeamStats,
species: Species,
movesSoFar: Move[],
ability: string
): number {
if (!move.exists) return 0;
// this is NOT doubles, so there will be no adjacent ally
if (move.target === 'adjacentAlly') return 0;
if (move.category === 'Status') {
// The initial value of this weight determines how valuable status moves are vs. attacking moves.
// You can raise it to make random status moves more valuable or lower it and increase multipliers
// to make only CERTAIN status moves valuable.
let weight = 2500;
// inflicts status
if (move.status) weight *= TeamGenerator.statusWeight(move.status) * 2;
// hazard setters: very important, but we don't need 2 pokemon to set the same hazard on a team
const isHazard = (m: Move) => m.sideCondition && m.target === 'foeSide';
if (isHazard(move) && (teamStats.hazardSetters[move.id] || 0) < 1) {
weight *= move.id === 'spikes' ? 12 : 16;
// if we are ALREADY setting hazards, setting MORE is really good
if (movesSoFar.some(m => isHazard(m))) weight *= 2;
teamStats.hazardSetters[move.id]++;
}
// boosts
weight *= this.boostWeight(move, movesSoFar, species) * 2;
weight *= this.opponentDebuffWeight(move) * 2;
// protection moves - useful for bulky/stally pokemon
if (species.baseStats.def >= 100 || species.baseStats.spd >= 100 || species.baseStats.hp >= 100) {
switch (move.volatileStatus) {
case 'endure':
weight *= 3;
break;
case 'protect': case 'kingsshield': case 'silktrap':
weight *= 4;
break;
case 'banefulbunker': case 'spikyshield':
weight *= 5;
break;
default:
break;
}
}
// Hardcoded boosts
if (move.id in HARDCODED_MOVE_WEIGHTS) weight *= HARDCODED_MOVE_WEIGHTS[move.id];
// Pokémon with high Attack and Special Attack stats shouldn't have too many status moves,
// but on bulkier Pokémon it's more likely to be worth it.
const goodAttacker = species.baseStats.atk > 80 || species.baseStats.spa > 80;
if (goodAttacker && movesSoFar.filter(m => m.category !== 'Status').length < 2) {
weight *= 0.3;
}
return weight;
}
// For Grass Knot and friends, let's just assume they average out to around 60 base power.
const isWeirdPowerMove = WEIGHT_BASED_MOVES.includes(move.id);
let basePower = isWeirdPowerMove ? 60 : move.basePower;
// not how this calc works but it should be close enough
if (SPEED_BASED_MOVES.includes(move.id)) basePower = species.baseStats.spe / 2;
const baseStat = move.category === 'Physical' ? species.baseStats.atk : species.baseStats.spa;
// 10% bonus for never-miss moves
const accuracy = move.accuracy === true ? 1.1 : move.accuracy / 100;
let powerEstimate = basePower * baseStat * accuracy;
// STAB
if (species.types.includes(move.type)) powerEstimate *= ability === 'Adaptability' ? 2 : 1.5;
if (ability === 'Technician' && move.basePower <= 60) powerEstimate *= 1.5;
if (ability === 'Steely Spirit' && move.type === 'Steel') powerEstimate *= 1.5;
if (move.multihit) {
const numberOfHits = Array.isArray(move.multihit) ?
(ability === 'Skill Link' ? move.multihit[1] : (move.multihit[0] + move.multihit[1]) / 2) :
move.multihit;
powerEstimate *= numberOfHits;
}
// If it uses the attacking stat that we don't boost, it's less useful!
const hasSpecialSetup = movesSoFar.some(m => m.boosts?.spa || m.self?.boosts?.spa || m.selfBoost?.boosts?.spa);
const hasPhysicalSetup = movesSoFar.some(m => m.boosts?.atk || m.self?.boosts?.atk || m.selfBoost?.boosts?.atk);
if (move.category === 'Physical' && hasSpecialSetup) powerEstimate *= 0.7;
if (move.category === 'Special' && hasPhysicalSetup) powerEstimate *= 0.7;
const abilityBonus = (
((ABILITY_MOVE_BONUSES[ability] || {})[move.id] || 1) *
((ABILITY_MOVE_TYPE_BONUSES[ability] || {})[move.type] || 1)
);
let weight = powerEstimate * abilityBonus;
if (move.id in HARDCODED_MOVE_WEIGHTS) weight *= HARDCODED_MOVE_WEIGHTS[move.id];
// priority is more useful when you're slower
if (move.priority > 0) weight *= (Math.max(130 - species.baseStats.spe, 0) / 130) * 0.5 + 1;
if (move.priority < 0) weight *= Math.min((1 / species.baseStats.spe) * 30, 1);
// flags
if (move.flags.charge || (move.flags.recharge && ability !== 'Truant')) weight *= 0.5;
if (move.flags.contact) {
if (ability === 'Tough Claws') weight *= 1.3;
if (ability === 'Unseen Fist') weight *= 1.1;
}
if (move.flags.bite && ability === 'Strong Jaw') weight *= 1.5;
// 10% boost for ability to break subs
if (move.flags.bypasssub) weight *= 1.1;
if (move.flags.pulse && ability === 'Mega Launcher') weight *= 1.5;
if (move.flags.punch && ability === 'Iron Fist') weight *= 1.2;
if (!move.flags.protect) weight *= 1.1;
if (move.flags.slicing && ability === 'Sharpness') weight *= 1.5;
// boosts/secondaries
// TODO: consider more possible secondaries
weight *= this.boostWeight(move, movesSoFar, species);
if (move.secondary?.status) {
weight *= TeamGenerator.statusWeight(move.secondary.status, (move.secondary.chance || 100) / 100);
}
// self-inflicted confusion or locking yourself in
if (move.self?.volatileStatus) weight *= 0.8;
// downweight moves if we already have an attacking move of the same type
if (movesSoFar.some(m => m.category !== 'Status' && m.type === move.type && m.basePower >= 60)) weight *= 0.3;
if (move.selfdestruct) weight *= 0.3;
if (move.recoil) weight *= 1 - (move.recoil[0] / move.recoil[1]);
if (move.mindBlownRecoil) weight *= 0.25;
if (move.flags['futuremove']) weight *= 0.3;
// TODO: account for normal higher-crit-chance moves
if (move.willCrit) weight *= 1.45;
if (move.drain) {
const drainedFraction = move.drain[0] / move.drain[1];
weight *= 1 + (drainedFraction * 0.5);
}
// don't need 2 healing moves
if (move.heal && movesSoFar.some(m => m.heal)) weight *= 0.5;
return weight;
}
/**
* @returns A multiplier to a move weighting based on the status it inflicts.
*/
protected static statusWeight(status: string, chance = 1): number {
if (chance !== 1) return 1 + (TeamGenerator.statusWeight(status) - 1) * chance;
switch (status) {
case 'brn': return 1.5;
case 'frz': return 5;
case 'par': return 1.5;
case 'psn': return 1.5;
case 'tox': return 4;
case 'slp': return 4;
}
return 1;
}
/**
* @returns A multiplier to a move weighting based on the boosts it produces for the user.
*/
protected boostWeight(move: Move, movesSoFar: Move[], species: Species): number {
const physicalIsRelevant = (
move.category === 'Physical' ||
movesSoFar.some(m => m.category === 'Physical')
);
const specialIsRelevant = (
move.category === 'Special' ||
movesSoFar.some(m => m.category === 'Special')
);
let weight = 1;
for (const {chance, boosts} of [
{chance: 1, boosts: move.boosts},
{chance: 1, boosts: move.self?.boosts},
{chance: 1, boosts: move.selfBoost?.boosts},
{
chance: move.secondary ? ((move.secondary.chance || 100) / 100) : 0,
boosts: move.target === 'self' ? move.secondary?.boosts : move.secondary?.self?.boosts,
},
]) {
if (!boosts || chance === 0) continue;
if (boosts.atk && physicalIsRelevant) weight += (chance || 1) * 0.5 * boosts.atk;
if (boosts.spa && specialIsRelevant) weight += (chance || 1) * 0.5 * boosts.spa;
// TODO: should these scale by base stat magnitude instead of using ternaries?
// defense/special defense boost is less useful if we have some bulk to start with
if (boosts.def) weight += (chance || 1) * 0.5 * boosts.def * (species.baseStats.def > 75 ? 1 : 0.5);
if (boosts.spd) weight += (chance || 1) * 0.5 * boosts.spd * (species.baseStats.spd > 75 ? 1 : 0.5);
// speed boost is less useful for fast pokemon
if (boosts.spe) weight += (chance || 1) * 0.5 * boosts.spe * (species.baseStats.spe > 120 ? 0.5 : 1);
}
return weight;
}
/**
* @returns A weight for a move based on how much it will reduce the opponent's stats.
*/
protected opponentDebuffWeight(move: Move): number {
if (!['allAdjacentFoes', 'allAdjacent', 'foeSide', 'normal'].includes(move.target)) return 1;
let averageNumberOfDebuffs = 0;
for (const {chance, boosts} of [
{chance: 1, boosts: move.boosts},
{
chance: move.secondary ? ((move.secondary.chance || 100) / 100) : 0,
boosts: move.secondary?.boosts,
},
]) {
if (!boosts || chance === 0) continue;
const numBoosts = Object.values(boosts).filter(x => x < 0).length;
averageNumberOfDebuffs += chance * numBoosts;
}
return 1 + (0.25 * averageNumberOfDebuffs);
}
/**
* @returns A weight for an item.
*/
protected getItemWeight(item: Item, teamStats: TeamStats, species: Species, moves: Move[], ability: string): number {
let weight;
switch (item.id) {
// Choice Items
case 'choiceband':
return moves.every(x => x.category === 'Physical') ? 50 : 0;
case 'choicespecs':
return moves.every(x => x.category === 'Special') ? 50 : 0;
case 'choicescarf':
if (moves.some(x => x.category === 'Status')) return 0;
if (species.baseStats.spe > 65 && species.baseStats.spe < 120) return 50;
return 10;
// Generally Decent Items
case 'lifeorb':
return moves.filter(x => x.category !== 'Status').length * 8;
case 'focussash':
if (ability === 'Sturdy') return 0;
// frail
if (species.baseStats.hp < 80 && species.baseStats.def < 80 && species.baseStats.spd < 80) return 35;
return 10;
case 'heavydutyboots':
switch (this.dex.getEffectiveness('Rock', species)) {
case 1: return 30; // super effective
case 0: return 10; // neutral
}
return 5; // not very effective/other
case 'assaultvest':
if (moves.some(x => x.category === 'Status')) return 0;
return 30;
// status
case 'flameorb':
weight = ability === 'Guts' && !species.types.includes('Fire') ? 30 : 0;
if (moves.some(m => m.id === 'facade')) weight *= 2;
return weight;
case 'toxicorb':
if (species.types.includes('Poison')) return 0;
weight = 0;
if (ability === 'Poison Heal') weight += 25;
if (moves.some(m => m.id === 'facade')) weight += 25;
return weight;
// Healing
case 'leftovers':
return 20;
case 'blacksludge':
return species.types.includes('Poison') ? 40 : 0;
// berries
case 'sitrusberry': case 'magoberry':
return 20;
case 'throatspray':
if (moves.some(m => m.flags.sound) && moves.some(m => m.category === 'Special')) return 30;
return 0;
default:
// probably not a very good item
return 0;
}
}
/**
* @returns The level a Pokémon should be.
*/
protected static getLevel(species: Species): number {
if (levelOverride[species.id]) return levelOverride[species.id];
switch (species.tier) {
case 'Uber': return 70;
case 'OU': case 'Unreleased': return 80;
case 'UU': return 90;
case 'LC': case 'NFE': return 100;
}
return 100;
}
/**
* Picks a choice from `choices` based on the weights in `weights`.
* `weights` must be the same length as `choices`.
*/
weightedRandomPick<T>(
choices: T[],
weights: number[],
options?: {remove?: boolean}
) {
if (!choices.length) throw new Error(`Can't pick from an empty list`);
if (choices.length !== weights.length) throw new Error(`Choices and weights must be the same length`);
const totalWeight = weights.reduce((a, b) => a + b, 0);
let randomWeight = this.prng.next(0, totalWeight);
for (let i = 0; i < choices.length; i++) {
randomWeight -= weights[i];
if (randomWeight < 0) {
const choice = choices[i];
if (options?.remove) choices.splice(i, 1);
return choice;
}
}
if (options?.remove && choices.length) return choices.pop()!;
return choices[choices.length - 1];
}
setSeed(seed: PRNGSeed) {
this.prng.seed = seed;
}
}