pokemon-showdown/sim/teams.ts
Guangcong Luo 78439b4a02
Update to ESLint 9 (#10926)
ESLint has a whole new config format, so I figure it's a good time to
make the config system saner.

- First, we no longer have separate eslint-no-types configs. Lint
  performance shouldn't be enough of a problem to justify the
  relevant maintenance complexity.

- Second, our base config should work out-of-the-box now. `npx eslint`
  will work as expected, without any CLI flags. You should still use
  `npm run lint` which adds the `--cached` flag for performance.

- Third, whatever updates I did fixed style linting, which apparently
  has been bugged for quite some time, considering all the obvious
  mixed-tabs-and-spaces issues I found in the upgrade.

Also here are some changes to our style rules. In particular:

- Curly brackets (for objects etc) now have spaces inside them. Sorry
  for the huge change. ESLint doesn't support our old style, and most
  projects use Prettier style, so we might as well match them in this way.
  See https://github.com/eslint-stylistic/eslint-stylistic/issues/415

- String + number concatenation is no longer allowed. We now
  consistently use template strings for this.
2025-02-25 20:03:46 -08:00

644 lines
18 KiB
TypeScript

/**
* Teams
* Pokemon Showdown - http://pokemonshowdown.com/
*
* Functions for converting and generating teams.
*
* @license MIT
*/
import { Dex, toID } from './dex';
import type { PRNG, PRNGSeed } from './prng';
export interface PokemonSet {
/**
* Nickname. Should be identical to its base species if not specified
* by the player, e.g. "Minior".
*/
name: string;
/**
* Species name (including forme if applicable), e.g. "Minior-Red".
* This should always be converted to an id before use.
*/
species: string;
/**
* This can be an id, e.g. "whiteherb" or a full name, e.g. "White Herb".
* This should always be converted to an id before use.
*/
item: string;
/**
* This can be an id, e.g. "shieldsdown" or a full name,
* e.g. "Shields Down".
* This should always be converted to an id before use.
*/
ability: string;
/**
* Each move can be an id, e.g. "shellsmash" or a full name,
* e.g. "Shell Smash"
* These should always be converted to ids before use.
*/
moves: string[];
/**
* This can be an id, e.g. "adamant" or a full name, e.g. "Adamant".
* This should always be converted to an id before use.
*/
nature: string;
gender: string;
/**
* Effort Values, used in stat calculation.
* These must be between 0 and 255, inclusive.
*
* Also used to store AVs for Let's Go
*/
evs: StatsTable;
/**
* Individual Values, used in stat calculation.
* These must be between 0 and 31, inclusive.
*
* These are also used as DVs, or determinant values, in Gens
* 1 and 2, which are represented as even numbers from 0 to 30.
*
* In Gen 2-6, these must match the Hidden Power type.
*
* In Gen 7+, Bottle Caps means these can either match the
* Hidden Power type or 31.
*/
ivs: StatsTable;
/**
* This is usually between 1 and 100, inclusive,
* but the simulator supports levels up to 9999 for testing purposes.
*/
level: number;
/**
* While having no direct competitive effect, certain Pokemon cannot
* be legally obtained as shiny, either as a whole or with certain
* event-only abilities or moves.
*/
shiny?: boolean;
/**
* This is technically "Friendship", but the community calls this
* "Happiness".
*
* It's used to calculate the power of the moves Return and Frustration.
* This value must be between 0 and 255, inclusive.
*/
happiness?: number;
/**
* The pokeball this Pokemon is in. Like shininess, this property
* has no direct competitive effects, but has implications for
* event legality. For example, any Rayquaza that knows V-Create
* must be sent out from a Cherish Ball.
*
* TODO: actually support this in the validator, switching animations,
* and the teambuilder.
*/
pokeball?: string;
/**
* Hidden Power type. Optional in older gens, but used in Gen 7+
* because `ivs` contain post-Battle-Cap values.
*/
hpType?: string;
/**
* Dynamax Level. Affects the amount of HP gained when Dynamaxed.
* This value must be between 0 and 10, inclusive.
*/
dynamaxLevel?: number;
gigantamax?: boolean;
/**
* Tera Type
*/
teraType?: string;
}
export const Teams = new class Teams {
pack(team: PokemonSet[] | null): string {
if (!team) return '';
function getIv(ivs: StatsTable, s: keyof StatsTable): string {
return ivs[s] === 31 || ivs[s] === undefined ? '' : ivs[s].toString();
}
let buf = '';
for (const set of team) {
if (buf) buf += ']';
// name
buf += (set.name || set.species);
// species
const id = this.packName(set.species || set.name);
buf += `|${this.packName(set.name || set.species) === id ? '' : id}`;
// item
buf += `|${this.packName(set.item)}`;
// ability
buf += `|${this.packName(set.ability)}`;
// moves
buf += '|' + set.moves.map(this.packName).join(',');
// nature
buf += `|${set.nature || ''}`;
// evs
let evs = '|';
if (set.evs) {
evs = `|${set.evs['hp'] || ''},${set.evs['atk'] || ''},${set.evs['def'] || ''},` +
`${set.evs['spa'] || ''},${set.evs['spd'] || ''},${set.evs['spe'] || ''}`;
}
if (evs === '|,,,,,') {
buf += '|';
} else {
buf += evs;
}
// gender
if (set.gender) {
buf += `|${set.gender}`;
} else {
buf += '|';
}
// ivs
let ivs = '|';
if (set.ivs) {
ivs = `|${getIv(set.ivs, 'hp')},${getIv(set.ivs, 'atk')},${getIv(set.ivs, 'def')},` +
`${getIv(set.ivs, 'spa')},${getIv(set.ivs, 'spd')},${getIv(set.ivs, 'spe')}`;
}
if (ivs === '|,,,,,') {
buf += '|';
} else {
buf += ivs;
}
// shiny
if (set.shiny) {
buf += '|S';
} else {
buf += '|';
}
// level
if (set.level && set.level !== 100) {
buf += `|${set.level}`;
} else {
buf += '|';
}
// happiness
if (set.happiness !== undefined && set.happiness !== 255) {
buf += `|${set.happiness}`;
} else {
buf += '|';
}
if (set.pokeball || set.hpType || set.gigantamax ||
(set.dynamaxLevel !== undefined && set.dynamaxLevel !== 10) || set.teraType) {
buf += `,${set.hpType || ''}`;
buf += `,${this.packName(set.pokeball || '')}`;
buf += `,${set.gigantamax ? 'G' : ''}`;
buf += `,${set.dynamaxLevel !== undefined && set.dynamaxLevel !== 10 ? set.dynamaxLevel : ''}`;
buf += `,${set.teraType || ''}`;
}
}
return buf;
}
unpack(buf: string): PokemonSet[] | null {
if (!buf) return null;
if (typeof buf !== 'string') return buf;
if (buf.startsWith('[') && buf.endsWith(']')) {
try {
buf = this.pack(JSON.parse(buf));
} catch {
return null;
}
}
const team = [];
let i = 0;
let j = 0;
// limit to 24
for (let count = 0; count < 24; count++) {
const set: PokemonSet = {} as PokemonSet;
team.push(set);
// name
j = buf.indexOf('|', i);
if (j < 0) return null;
set.name = buf.substring(i, j);
i = j + 1;
// species
j = buf.indexOf('|', i);
if (j < 0) return null;
set.species = this.unpackName(buf.substring(i, j), Dex.species) || set.name;
i = j + 1;
// item
j = buf.indexOf('|', i);
if (j < 0) return null;
set.item = this.unpackName(buf.substring(i, j), Dex.items);
i = j + 1;
// ability
j = buf.indexOf('|', i);
if (j < 0) return null;
const ability = buf.substring(i, j);
const species = Dex.species.get(set.species);
set.ability = ['', '0', '1', 'H', 'S'].includes(ability) ?
species.abilities[ability as '0' || '0'] || (ability === '' ? '' : '!!!ERROR!!!') :
this.unpackName(ability, Dex.abilities);
i = j + 1;
// moves
j = buf.indexOf('|', i);
if (j < 0) return null;
set.moves = buf.substring(i, j).split(',', 24).map(name => this.unpackName(name, Dex.moves));
i = j + 1;
// nature
j = buf.indexOf('|', i);
if (j < 0) return null;
set.nature = this.unpackName(buf.substring(i, j), Dex.natures);
i = j + 1;
// evs
j = buf.indexOf('|', i);
if (j < 0) return null;
if (j !== i) {
const evs = buf.substring(i, j).split(',', 6);
set.evs = {
hp: Number(evs[0]) || 0,
atk: Number(evs[1]) || 0,
def: Number(evs[2]) || 0,
spa: Number(evs[3]) || 0,
spd: Number(evs[4]) || 0,
spe: Number(evs[5]) || 0,
};
}
i = j + 1;
// gender
j = buf.indexOf('|', i);
if (j < 0) return null;
if (i !== j) set.gender = buf.substring(i, j);
i = j + 1;
// ivs
j = buf.indexOf('|', i);
if (j < 0) return null;
if (j !== i) {
const ivs = buf.substring(i, j).split(',', 6);
set.ivs = {
hp: ivs[0] === '' ? 31 : Number(ivs[0]) || 0,
atk: ivs[1] === '' ? 31 : Number(ivs[1]) || 0,
def: ivs[2] === '' ? 31 : Number(ivs[2]) || 0,
spa: ivs[3] === '' ? 31 : Number(ivs[3]) || 0,
spd: ivs[4] === '' ? 31 : Number(ivs[4]) || 0,
spe: ivs[5] === '' ? 31 : Number(ivs[5]) || 0,
};
}
i = j + 1;
// shiny
j = buf.indexOf('|', i);
if (j < 0) return null;
if (i !== j) set.shiny = true;
i = j + 1;
// level
j = buf.indexOf('|', i);
if (j < 0) return null;
if (i !== j) set.level = parseInt(buf.substring(i, j));
i = j + 1;
// happiness
j = buf.indexOf(']', i);
let misc;
if (j < 0) {
if (i < buf.length) misc = buf.substring(i).split(',', 6);
} else {
if (i !== j) misc = buf.substring(i, j).split(',', 6);
}
if (misc) {
set.happiness = (misc[0] ? Number(misc[0]) : 255);
set.hpType = misc[1] || '';
set.pokeball = this.unpackName(misc[2] || '', Dex.items);
set.gigantamax = !!misc[3];
set.dynamaxLevel = (misc[4] ? Number(misc[4]) : 10);
set.teraType = misc[5];
}
if (j < 0) break;
i = j + 1;
}
return team;
}
/** Very similar to toID but without the lowercase conversion */
packName(this: void, name: string | undefined | null) {
if (!name) return '';
return name.replace(/[^A-Za-z0-9]+/g, '');
}
/** Will not entirely recover a packed name, but will be a pretty readable guess */
unpackName(name: string, dexTable?: { get: (name: string) => AnyObject }) {
if (!name) return '';
if (dexTable) {
const obj = dexTable.get(name);
if (obj.exists) return obj.name;
}
return name.replace(/([0-9]+)/g, ' $1 ').replace(/([A-Z])/g, ' $1').replace(/[ ][ ]/g, ' ').trim();
}
/**
* Exports a team in human-readable PS export format
*/
export(team: PokemonSet[], options?: { hideStats?: boolean }) {
let output = '';
for (const set of team) {
output += this.exportSet(set, options) + `\n`;
}
return output;
}
exportSet(set: PokemonSet, { hideStats }: { hideStats?: boolean } = {}) {
let out = ``;
// core
if (set.name && set.name !== set.species) {
out += `${set.name} (${set.species})`;
} else {
out += set.species;
}
if (set.gender === 'M') out += ` (M)`;
if (set.gender === 'F') out += ` (F)`;
if (set.item) out += ` @ ${set.item}`;
out += ` \n`;
if (set.ability) {
out += `Ability: ${set.ability} \n`;
}
// details
if (set.level && set.level !== 100) {
out += `Level: ${set.level} \n`;
}
if (set.shiny) {
out += `Shiny: Yes \n`;
}
if (typeof set.happiness === 'number' && set.happiness !== 255 && !isNaN(set.happiness)) {
out += `Happiness: ${set.happiness} \n`;
}
if (set.pokeball) {
out += `Pokeball: ${set.pokeball} \n`;
}
if (set.hpType) {
out += `Hidden Power: ${set.hpType} \n`;
}
if (typeof set.dynamaxLevel === 'number' && set.dynamaxLevel !== 10 && !isNaN(set.dynamaxLevel)) {
out += `Dynamax Level: ${set.dynamaxLevel} \n`;
}
if (set.gigantamax) {
out += `Gigantamax: Yes \n`;
}
if (set.teraType) {
out += `Tera Type: ${set.teraType} \n`;
}
// stats
if (!hideStats) {
if (set.evs) {
const stats = Dex.stats.ids().map(
stat => set.evs[stat] ?
`${set.evs[stat]} ${Dex.stats.shortNames[stat]}` : ``
).filter(Boolean);
if (stats.length) {
out += `EVs: ${stats.join(" / ")} \n`;
}
}
if (set.nature) {
out += `${set.nature} Nature \n`;
}
if (set.ivs) {
const stats = Dex.stats.ids().map(
stat => (set.ivs[stat] !== 31 && set.ivs[stat] !== undefined) ?
`${set.ivs[stat] || 0} ${Dex.stats.shortNames[stat]}` : ``
).filter(Boolean);
if (stats.length) {
out += `IVs: ${stats.join(" / ")} \n`;
}
}
}
// moves
for (let move of set.moves) {
if (move.startsWith(`Hidden Power `) && move.charAt(13) !== '[') {
move = `Hidden Power [${move.slice(13)}]`;
}
out += `- ${move} \n`;
}
return out;
}
parseExportedTeamLine(line: string, isFirstLine: boolean, set: PokemonSet, aggressive?: boolean) {
if (isFirstLine) {
let item;
[line, item] = line.split(' @ ');
if (item) {
set.item = item;
if (toID(set.item) === 'noitem') set.item = '';
}
if (line.endsWith(' (M)')) {
set.gender = 'M';
line = line.slice(0, -4);
}
if (line.endsWith(' (F)')) {
set.gender = 'F';
line = line.slice(0, -4);
}
if (line.endsWith(')') && line.includes('(')) {
const [name, species] = line.slice(0, -1).split('(');
set.species = Dex.species.get(species).name;
set.name = name.trim();
} else {
set.species = Dex.species.get(line).name;
set.name = '';
}
} else if (line.startsWith('Trait: ')) {
line = line.slice(7);
set.ability = aggressive ? toID(line) : line;
} else if (line.startsWith('Ability: ')) {
line = line.slice(9);
set.ability = aggressive ? toID(line) : line;
} else if (line === 'Shiny: Yes') {
set.shiny = true;
} else if (line.startsWith('Level: ')) {
line = line.slice(7);
set.level = +line;
} else if (line.startsWith('Happiness: ')) {
line = line.slice(11);
set.happiness = +line;
} else if (line.startsWith('Pokeball: ')) {
line = line.slice(10);
set.pokeball = aggressive ? toID(line) : line;
} else if (line.startsWith('Hidden Power: ')) {
line = line.slice(14);
set.hpType = aggressive ? toID(line) : line;
} else if (line.startsWith('Tera Type: ')) {
line = line.slice(11);
set.teraType = aggressive ? line.replace(/[^a-zA-Z0-9]/g, '') : line;
} else if (line === 'Gigantamax: Yes') {
set.gigantamax = true;
} else if (line.startsWith('EVs: ')) {
line = line.slice(5);
const evLines = line.split('/');
set.evs = { hp: 0, atk: 0, def: 0, spa: 0, spd: 0, spe: 0 };
for (const evLine of evLines) {
const [statValue, statName] = evLine.trim().split(' ');
const statid = Dex.stats.getID(statName);
if (!statid) continue;
const value = parseInt(statValue);
set.evs[statid] = value;
}
} else if (line.startsWith('IVs: ')) {
line = line.slice(5);
const ivLines = line.split('/');
set.ivs = { hp: 31, atk: 31, def: 31, spa: 31, spd: 31, spe: 31 };
for (const ivLine of ivLines) {
const [statValue, statName] = ivLine.trim().split(' ');
const statid = Dex.stats.getID(statName);
if (!statid) continue;
let value = parseInt(statValue);
if (isNaN(value)) value = 31;
set.ivs[statid] = value;
}
} else if (/^[A-Za-z]+ (N|n)ature/.test(line)) {
let natureIndex = line.indexOf(' Nature');
if (natureIndex === -1) natureIndex = line.indexOf(' nature');
if (natureIndex === -1) return;
line = line.substr(0, natureIndex);
if (line !== 'undefined') set.nature = aggressive ? toID(line) : line;
} else if (line.startsWith('-') || line.startsWith('~')) {
line = line.slice(line.charAt(1) === ' ' ? 2 : 1);
if (line.startsWith('Hidden Power [')) {
const hpType = line.slice(14, -1);
line = 'Hidden Power ' + hpType;
if (!set.ivs && Dex.types.isName(hpType)) {
set.ivs = { hp: 31, atk: 31, def: 31, spa: 31, spd: 31, spe: 31 };
const hpIVs = Dex.types.get(hpType).HPivs || {};
for (const statid in hpIVs) {
set.ivs[statid as StatID] = hpIVs[statid as StatID]!;
}
}
}
if (line === 'Frustration' && set.happiness === undefined) {
set.happiness = 0;
}
set.moves.push(line);
}
}
/** Accepts a team in any format (JSON, packed, or exported) */
import(buffer: string, aggressive?: boolean): PokemonSet[] | null {
const sanitize = aggressive ? toID : Dex.getName;
if (buffer.startsWith('[')) {
try {
const team = JSON.parse(buffer);
if (!Array.isArray(team)) throw new Error(`Team should be an Array but isn't`);
for (const set of team) {
set.name = sanitize(set.name);
set.species = sanitize(set.species);
set.item = sanitize(set.item);
set.ability = sanitize(set.ability);
set.gender = sanitize(set.gender);
set.nature = sanitize(set.nature);
const evs = { hp: 0, atk: 0, def: 0, spa: 0, spd: 0, spe: 0 };
if (set.evs) {
for (const statid in evs) {
if (typeof set.evs[statid] === 'number') evs[statid as StatID] = set.evs[statid];
}
}
set.evs = evs;
const ivs = { hp: 31, atk: 31, def: 31, spa: 31, spd: 31, spe: 31 };
if (set.ivs) {
for (const statid in ivs) {
if (typeof set.ivs[statid] === 'number') ivs[statid as StatID] = set.ivs[statid];
}
}
set.ivs = ivs;
if (!Array.isArray(set.moves)) {
set.moves = [];
} else {
set.moves = set.moves.map(sanitize);
}
}
return team;
} catch {}
}
const lines = buffer.split("\n");
const sets: PokemonSet[] = [];
let curSet: PokemonSet | null = null;
while (lines.length && !lines[0]) lines.shift();
while (lines.length && !lines[lines.length - 1]) lines.pop();
if (lines.length === 1 && lines[0].includes('|')) {
return this.unpack(lines[0]);
}
for (let line of lines) {
line = line.trim();
if (line === '' || line === '---') {
curSet = null;
} else if (line.startsWith('===')) {
// team backup format; ignore
} else if (!curSet) {
curSet = {
name: '', species: '', item: '', ability: '', gender: '',
nature: '',
evs: { hp: 0, atk: 0, def: 0, spa: 0, spd: 0, spe: 0 },
ivs: { hp: 31, atk: 31, def: 31, spa: 31, spd: 31, spe: 31 },
level: 100,
moves: [],
};
sets.push(curSet);
this.parseExportedTeamLine(line, true, curSet, aggressive);
} else {
this.parseExportedTeamLine(line, false, curSet, aggressive);
}
}
return sets;
}
getGenerator(format: Format | string, seed: PRNG | PRNGSeed | null = null) {
let TeamGenerator;
format = Dex.formats.get(format);
const formatID = toID(format);
if (formatID.includes('gen9computergeneratedteams')) {
TeamGenerator = require(Dex.forFormat(format).dataDir + '/cg-teams').default;
} else if (formatID.includes('gen9superstaffbrosultimate')) {
TeamGenerator = require(`../data/mods/gen9ssb/random-teams`).default;
} else if (formatID.includes('gen9babyrandombattle')) {
TeamGenerator = require(`../data/random-battles/gen9baby/teams`).default;
} else if (formatID.includes('gen9randombattle') && format.ruleTable?.has('+pokemontag:cap')) {
TeamGenerator = require(`../data/random-battles/gen9cap/teams`).default;
} else {
TeamGenerator = require(`../data/random-battles/${format.mod}/teams`).default;
}
return new TeamGenerator(format, seed);
}
generate(format: Format | string, options: PlayerOptions | null = null): PokemonSet[] {
return this.getGenerator(format, options?.seed).getTeam(options);
}
};
export default Teams;