pokemon-showdown-client/play.pokemonshowdown.com/src/battle-team-editor.tsx
pyuk-bot 7f4f9755e8
Some checks are pending
Node.js CI / build (22.x) (push) Waiting to run
Preact: Update nature after applying optimizer (#2557)
Untested, but I think it’s pretty obvious that this is why the optimizer isn’t changing nature currently when applied. The function above is basically the same function, but with this extra line.
2025-12-03 04:34:43 -08:00

3219 lines
105 KiB
TypeScript

/**
* Teambuilder team editor, extracted from the rest of the Preact
* client so that it can be used in isolation.
*
* @author Guangcong Luo <guangcongluo@gmail.com>
* @license AGPLv3
*/
import preact from "../js/lib/preact";
import { type Team, Config, PS } from "./client-main";
import { Dex, type ModdedDex, toID, type ID, PSUtils } from "./battle-dex";
import { Teams } from './battle-teams';
import { DexSearch, type SearchRow, type SearchType } from "./battle-dex-search";
import { PSSearchResults } from "./battle-searchresults";
import { BattleNatures, BattleStatNames, type StatName } from "./battle-dex-data";
import { BattleStatGuesser, BattleStatOptimizer } from "./battle-tooltips";
import { PSModel } from "./client-core";
import { Net } from "./client-connection";
import { PSIcon } from "./panels";
type SelectionType = 'pokemon' | 'ability' | 'item' | 'move' | 'stats' | 'details';
type SampleSets = {
[speciesName: string]: {
[setName: string]: Dex.PokemonSet,
},
};
type SampleSetsTable = { dex?: SampleSets, stats?: SampleSets };
export class TeamEditorState extends PSModel {
static clipboard: {
teams: {
[teamKey: string]: {
team: Team,
sets: { [index: number]: Dex.PokemonSet },
/** was the team added from the team list rather than the team editor's set list?
* (if yes, delete the team itself when moving it) */
entire: boolean,
},
} | null,
otherSets: Dex.PokemonSet[] | null,
readonly: boolean,
} | null = null;
team: Team;
sets: Dex.PokemonSet[] = [];
lastPackedTeam = '';
gen = Dex.gen;
dex: ModdedDex = Dex;
deletedSet: {
set: Dex.PokemonSet,
index: number,
} | null = null;
search = new DexSearch();
format: ID = `gen${this.gen}` as ID;
searchIndex = 0;
originalSpecies: string | null = null;
narrow = false;
selectionTypeOrder: readonly SelectionType[] = [
'pokemon', 'ability', 'item', 'move', 'stats', 'details',
];
innerFocus: {
setIndex: number,
type: SelectionType,
typeIndex?: number,
} | null = null;
isLetsGo = false;
isNatDex = false;
isBDSP = false;
formeLegality: 'normal' | 'hackmons' | 'custom' = 'normal';
abilityLegality: 'normal' | 'hackmons' = 'normal';
defaultLevel = 100;
readonly = false;
fetching = false;
private userSetsCache: Record<ID, { [species: string]: { [setName: string]: Dex.PokemonSet } }> = {};
constructor(team: Team) {
super();
this.team = team;
this.updateTeam(false);
this.setFormat(team.format);
window.search = this.search;
}
updateTeam(readonly: boolean) {
if (this.lastPackedTeam !== this.team.packedTeam) {
this.sets = Teams.unpack(this.team.packedTeam);
this.lastPackedTeam = this.team.packedTeam;
}
this.readonly = readonly;
}
setFormat(format: string) {
const team = this.team;
const formatid = toID(format);
this.format = formatid;
team.format = formatid;
this.dex = Dex.forFormat(formatid);
this.gen = this.dex.gen;
format = toID(format).slice(4);
this.isLetsGo = formatid.includes('letsgo');
this.isNatDex = formatid.includes('nationaldex') || formatid.includes('natdex');
this.isBDSP = formatid.includes('bdsp');
if (formatid.includes('almostanyability') || formatid.includes('aaa')) {
this.abilityLegality = 'hackmons';
} else {
this.abilityLegality = 'normal';
}
if (formatid.includes('hackmons') || formatid.includes('bh')) {
this.formeLegality = 'hackmons';
this.abilityLegality = 'hackmons';
} else if (formatid.includes('metronome') || formatid.includes('customgame')) {
this.formeLegality = 'custom';
this.abilityLegality = 'hackmons';
} else {
this.formeLegality = 'normal';
}
this.defaultLevel = 100;
if (
formatid.includes('vgc') || formatid.includes('bss') || formatid.includes('ultrasinnohclassic') ||
formatid.includes('battlespot') || formatid.includes('battlestadium') || formatid.includes('battlefestival')
) {
this.defaultLevel = 50;
}
if (formatid.includes('lc')) {
this.defaultLevel = 5;
}
}
setSearchType(type: SearchType, i: number, value?: string) {
const set = this.sets[i];
this.search.setType(type, this.format, set);
this.originalSpecies = null;
this.search.prependResults = null;
if (type === 'move') {
this.search.prependResults = this.getSearchMoves(set);
if (value && this.search.prependResults.some(row => row[1].split('_')[2] === toID(value))) {
value = '';
}
} else if (value) {
switch (type) {
case 'pokemon':
if (this.dex.species.get(value).exists) {
this.originalSpecies = value;
this.search.prependResults = [['pokemon', toID(value)]];
value = '';
}
break;
case 'item':
if (toID(value) === 'noitem') value = '';
if (this.dex.items.get(value).exists) {
this.search.prependResults = [['item', toID(value)]];
value = '';
}
break;
case 'ability':
if (toID(value) === 'selectability') value = '';
if (toID(value) === 'noability') value = '';
if (this.dex.abilities.get(value).exists) {
this.search.prependResults = [['ability', toID(value)]];
value = '';
}
break;
}
}
if (type === 'item') (this.search.prependResults ||= []).push(['item', '' as ID]);
this.search.find(value || '');
this.searchIndex = this.search.results?.[0]?.[0] === 'header' ? 1 : 0;
}
updateSearchMoves(set: Dex.PokemonSet) {
let oldResultsLength = this.search.prependResults?.length || 0;
this.search.prependResults = this.getSearchMoves(set);
this.searchIndex += this.search.prependResults.length - oldResultsLength;
if (this.searchIndex < 0) this.searchIndex = 0;
this.search.results = null;
if (this.search.query) {
this.setSearchValue('');
} else {
this.search.find('');
}
}
getSearchMoves(set: Dex.PokemonSet) {
const out: SearchRow[] = [];
for (let i = 0; i < Math.max(set.moves.length, 4); i++) {
out.push(['move', `_${i + 1}_${toID(set.moves[i] || '')}` as ID]);
}
return out;
}
setSearchValue(value: string) {
this.search.find(value);
this.searchIndex = this.search.results?.[0]?.[0] === 'header' ? 1 : 0;
}
selectSearchValue(): string | null {
let result = this.search.results?.[this.searchIndex];
if (result?.[0] === 'header') {
this.searchIndex++;
result = this.search.results?.[this.searchIndex];
}
if (!result) return null;
if (this.search.addFilter(result)) {
this.searchIndex = 0;
return null;
}
return this.getResultValue(result);
}
changeSpecies(set: Dex.PokemonSet, speciesName: string) {
const species = this.dex.species.get(speciesName);
if (set.item === this.getDefaultItem(set.species)) set.item = undefined;
if (set.name === set.species.split('-')[0]) delete set.name;
set.species = species.name;
set.ability = this.getDefaultAbility(set);
set.item = this.getDefaultItem(species.name) ?? set.item;
if (toID(speciesName) === 'Cathy') {
set.name = "Cathy";
set.species = 'Trevenant';
set.level = undefined;
set.gender = 'F';
set.item = 'Starf Berry';
set.ability = 'Harvest';
set.moves = ['Substitute', 'Horn Leech', 'Earthquake', 'Phantom Force'];
set.evs = { hp: 36, atk: 252, def: 0, spa: 0, spd: 0, spe: 220 };
set.ivs = undefined;
set.nature = 'Jolly';
}
}
deleteSet(index: number) {
if (this.sets.length <= index) return;
this.deletedSet = {
set: this.sets[index],
index,
};
this.sets.splice(index, 1);
}
undeleteSet() {
if (!this.deletedSet) return;
this.sets.splice(this.deletedSet.index, 0, this.deletedSet.set);
this.deletedSet = null;
}
copySet(index: number) {
if (this.sets.length <= index) return;
TeamEditorState.clipboard ||= {
teams: {},
otherSets: null,
readonly: false,
};
TeamEditorState.clipboard.teams ||= {};
TeamEditorState.clipboard.teams[this.team.key] ||= {
team: this.team, sets: {}, entire: false,
};
if (this.readonly) TeamEditorState.clipboard.readonly = true;
if (TeamEditorState.clipboard.teams[this.team.key].sets[index] === this.sets[index]) {
// remove
TeamEditorState.clipboard.teams[this.team.key].entire = false;
delete TeamEditorState.clipboard.teams[this.team.key].sets[index];
if (!Object.keys(TeamEditorState.clipboard.teams[this.team.key].sets).length) {
delete TeamEditorState.clipboard.teams[this.team.key];
}
if (!Object.keys(TeamEditorState.clipboard.teams).length) {
TeamEditorState.clipboard.teams = null;
if (!TeamEditorState.clipboard.otherSets) {
TeamEditorState.clipboard = null;
}
}
return;
}
TeamEditorState.clipboard.teams[this.team.key].sets[index] = this.sets[index];
}
static copyTeam(team: Team) {
TeamEditorState.clipboard ||= {
teams: {},
otherSets: null,
readonly: false,
};
TeamEditorState.clipboard.teams ||= {};
if (TeamEditorState.clipboard.teams[team.key]) {
// remove
delete TeamEditorState.clipboard.teams[team.key];
if (!Object.keys(TeamEditorState.clipboard.teams).length) {
TeamEditorState.clipboard.teams = null;
if (!TeamEditorState.clipboard.otherSets) {
TeamEditorState.clipboard = null;
}
}
return;
}
TeamEditorState.clipboard.teams[team.key] ||= {
team, sets: {}, entire: true,
};
const sets = Teams.unpack(team.packedTeam);
for (let i = 0; i < sets.length; i++) {
TeamEditorState.clipboard.teams[team.key].sets[i] = sets[i];
}
}
pasteSet(index: number, isMove?: boolean) {
if (!TeamEditorState.clipboard) return;
if (this.readonly) return;
if (isMove) {
if (TeamEditorState.clipboard.readonly) return;
for (const key in TeamEditorState.clipboard.teams) {
const clipboardTeam = TeamEditorState.clipboard.teams[key];
const sources = Object.keys(clipboardTeam.sets).map(Number);
// descending order, so splices won't affect future indices
sources.sort((a, b) => -(a - b));
for (const source of sources) {
if (key === this.team.key) {
this.sets.splice(source, 1);
if (source < index) index--;
} else {
const team = clipboardTeam.team;
const sets = Teams.unpack(team.packedTeam);
sets.splice(source, 1);
team.packedTeam = Teams.pack(sets);
team.iconCache = null;
}
}
}
}
const sets: Dex.PokemonSet[] = [];
for (const key in TeamEditorState.clipboard.teams) {
const clipboardTeam = TeamEditorState.clipboard.teams[key];
for (const set of Object.values(clipboardTeam.sets)) {
sets.push(set);
}
}
sets.push(...TeamEditorState.clipboard.otherSets || []);
for (const set of sets) {
// not the most efficient way to deepclone but we don't need efficiency here
const newSet = JSON.parse(JSON.stringify(set)) as Dex.PokemonSet;
this.sets.splice(index, 0, newSet);
index++;
}
TeamEditorState.clipboard = null;
this.save();
}
static pasteTeam(index: number, isMove?: boolean, folder = '') {
if (!TeamEditorState.clipboard) return;
if (isMove) {
if (TeamEditorState.clipboard.readonly) return;
const indexesToRemove: number[] = [];
for (const key in TeamEditorState.clipboard.teams) {
if (TeamEditorState.clipboard.teams[key].entire) {
const team = TeamEditorState.clipboard.teams[key].team;
const i = PS.teams.list.indexOf(team);
if (i >= 0) indexesToRemove.push(i);
}
}
// descending order, so splices won't affect future indices
indexesToRemove.sort((a, b) => -(a - b));
for (const i of indexesToRemove) {
PS.teams.list.splice(i, 1);
if (i < index) index--;
}
}
const teams: Team[] = [];
const sets: Teams.PokemonSet[] = [];
for (const key in TeamEditorState.clipboard.teams) {
const clipboardTeam = TeamEditorState.clipboard.teams[key];
if (clipboardTeam.entire) {
if (isMove) {
teams.push(clipboardTeam.team);
clipboardTeam.team.folder = folder;
} else {
const team: Team = {
name: `${clipboardTeam.team.name} (copy)`,
format: clipboardTeam.team.format,
folder,
packedTeam: clipboardTeam.team.packedTeam,
isBox: clipboardTeam.team.isBox,
iconCache: null,
key: '',
};
teams.push(team);
}
} else {
for (const set of Object.values(clipboardTeam.sets)) {
sets.push(set);
}
}
}
sets.push(...TeamEditorState.clipboard.otherSets || []);
if (sets.length) {
const team: Team = {
name: `Pasted Team`,
format: Dex.modid,
folder,
packedTeam: Teams.pack(sets),
isBox: false,
iconCache: null,
key: '',
};
teams.push(team);
}
PS.teams.spliceIn(index, teams);
TeamEditorState.clipboard = null;
}
ignoreRows = ['header', 'sortpokemon', 'sortmove', 'html'];
downSearchValue() {
if (!this.search.results || this.searchIndex >= this.search.results.length - 1) return;
this.searchIndex++;
if (this.ignoreRows.includes(this.search.results[this.searchIndex]?.[0])) {
if (this.searchIndex >= this.search.results.length - 1) return;
this.searchIndex++;
}
if (this.ignoreRows.includes(this.search.results[this.searchIndex]?.[0])) {
if (this.searchIndex >= this.search.results.length - 1) return;
this.searchIndex++;
}
}
upSearchValue() {
if (!this.search.results || this.searchIndex <= 0) return;
if (this.searchIndex <= 1 && this.ignoreRows.includes(this.search.results[0]?.[0])) return;
this.searchIndex--;
if (this.ignoreRows.includes(this.search.results[this.searchIndex]?.[0])) {
if (this.searchIndex <= 0) return;
this.searchIndex--;
}
if (this.ignoreRows.includes(this.search.results[this.searchIndex]?.[0])) {
if (this.searchIndex <= 0) return;
this.searchIndex--;
}
}
getResultValue(result: SearchRow): string {
switch (result[0]) {
case 'pokemon':
return this.dex.species.get(result[1]).name;
case 'item':
return this.dex.items.get(result[1]).name;
case 'ability':
return this.dex.abilities.get(result[1]).name;
case 'move':
if (result[1].startsWith('_')) {
const [slot, moveid] = result[1].slice(1).split('_');
return this.dex.moves.get(moveid).name + '|' + slot;
}
return this.dex.moves.get(result[1]).name;
case 'html':
case 'header':
return '';
default:
return result[1];
}
}
canAdd(): boolean {
return this.sets.length < 6 || this.team.isBox;
}
getHPType(set: Dex.PokemonSet): Dex.TypeName {
if (set.hpType) return set.hpType as Dex.TypeName;
const hpMove = set.ivs ? null : this.getHPMove(set);
if (hpMove) return hpMove;
const hpTypes = [
'Fighting', 'Flying', 'Poison', 'Ground', 'Rock', 'Bug', 'Ghost', 'Steel', 'Fire', 'Water', 'Grass', 'Electric', 'Psychic', 'Ice', 'Dragon', 'Dark',
] as const;
if (this.gen <= 2) {
if (!set.ivs) return 'Dark';
// const hpDV = Math.floor(set.ivs.hp / 2);
const atkDV = Math.floor(set.ivs.atk / 2);
const defDV = Math.floor(set.ivs.def / 2);
// const speDV = Math.floor(set.ivs.spe / 2);
// const spcDV = Math.floor(set.ivs.spa / 2);
// const expectedHpDV = (atkDV % 2) * 8 + (defDV % 2) * 4 + (speDV % 2) * 2 + (spcDV % 2);
// if (expectedHpDV !== hpDV) {
// set.ivs.hp = expectedHpDV * 2;
// if (set.ivs.hp === 30) set.ivs.hp = 31;
// }
return hpTypes[4 * (atkDV % 4) + (defDV % 4)];
} else {
const ivs = set.ivs || this.defaultIVs(set);
let hpTypeX = 0;
let i = 1;
// n.b. this is not our usual order (Spe and SpD are flipped)
const statOrder = ['hp', 'atk', 'def', 'spe', 'spa', 'spd'] as const;
for (const s of statOrder) {
if (ivs[s] === undefined) ivs[s] = 31;
hpTypeX += i * (ivs[s] % 2);
i *= 2;
}
return hpTypes[Math.floor(hpTypeX * 15 / 63)];
}
};
hpTypeMatters(set: Dex.PokemonSet): boolean {
if (this.gen < 2) return false;
if (this.gen > 7) return false;
for (const move of set.moves) {
const moveid = toID(move);
if (moveid.startsWith('hiddenpower')) return true;
if (moveid === 'transform') return true;
}
if (toID(set.ability) === 'imposter') return true;
return false;
}
getHPMove(set: Dex.PokemonSet): Dex.TypeName | null {
if (set.moves) {
for (const move of set.moves) {
const moveid = toID(move);
if (moveid.startsWith('hiddenpower')) {
return moveid.charAt(11).toUpperCase() + moveid.slice(12) as Dex.TypeName;
}
}
}
return null;
}
getIVs(set: Dex.PokemonSet) {
const ivs = this.defaultIVs(set);
if (set.ivs) Object.assign(ivs, set.ivs);
return ivs;
}
defaultIVs(set: Dex.PokemonSet, noGuess = !!set.ivs): Record<Dex.StatName, number> {
const useIVs = this.gen > 2;
const defaultIVs = { hp: 31, atk: 31, def: 31, spa: 31, spd: 31, spe: 31 };
if (!useIVs) {
for (const stat of Dex.statNames) defaultIVs[stat] = 15;
}
if (noGuess) return defaultIVs;
const hpType = this.getHPMove(set);
const hpModulo = (useIVs ? 2 : 4);
const { minAtk, minSpe } = this.prefersMinStats(set);
if (minAtk) defaultIVs['atk'] = 0;
if (minSpe) defaultIVs['spe'] = 0;
if (!useIVs) {
const hpDVs = hpType ? this.dex.types.get(hpType).HPdvs : null;
if (hpDVs) {
for (const stat in hpDVs) defaultIVs[stat as Dex.StatName] = hpDVs[stat as Dex.StatName]!;
}
} else {
const hpIVs = hpType ? this.dex.types.get(hpType).HPivs : null;
if (hpIVs) {
if (this.canHyperTrain(set)) {
if (minSpe) defaultIVs['spe'] = hpIVs['spe'] ?? 31;
if (minAtk) defaultIVs['atk'] = hpIVs['atk'] ?? 31;
} else {
for (const stat in hpIVs) defaultIVs[stat as Dex.StatName] = hpIVs[stat as Dex.StatName]!;
}
}
}
if (hpType) {
if (minSpe) defaultIVs['spe'] %= hpModulo;
if (minAtk) defaultIVs['atk'] %= hpModulo;
}
if (minAtk && useIVs) {
// min Atk
if (['Gouging Fire', 'Iron Boulder', 'Iron Crown', 'Raging Bolt'].includes(set.species)) {
// only available with 20 Atk IVs
defaultIVs['atk'] = 20;
} else if (set.species.startsWith('Terapagos')) {
// only available with 15 Atk IVs
defaultIVs['atk'] = 15;
}
}
return defaultIVs;
}
defaultHappiness(set: Dex.PokemonSet) {
if (set.moves.includes('Return')) return 255;
if (set.moves.includes('Frustration')) return 0;
return undefined;
}
prefersMinStats(set: Dex.PokemonSet) {
let minSpe = !set.evs?.spe && set.moves.includes('Gyro Ball');
let minAtk = !set.evs?.atk;
// only available through an event with 31 Spe IVs
if (set.species.startsWith('Terapagos')) minSpe = false;
if (this.format === 'gen7hiddentype') return { minAtk, minSpe };
if (this.format.includes('1v1')) return { minAtk, minSpe };
// only available through an event with 31 Atk IVs
if (set.ability === 'Battle Bond' || ['Koraidon', 'Miraidon', 'Gimmighoul-Roaming'].includes(set.species)) {
minAtk = false;
return { minAtk, minSpe };
}
if (!set.moves.length) minAtk = false;
for (const moveName of set.moves) {
if (!moveName) continue;
const move = this.dex.moves.get(moveName);
if (move.id === 'transform') {
const hasMoveBesidesTransform = set.moves.length > 1;
if (!hasMoveBesidesTransform) minAtk = false;
} else if (
move.category === 'Physical' && !move.damage && !move.ohko &&
!['foulplay', 'endeavor', 'counter', 'bodypress', 'seismictoss', 'bide', 'metalburst', 'superfang'].includes(move.id) &&
!(this.gen < 8 && move.id === 'rapidspin')
) {
minAtk = false;
} else if (
['metronome', 'assist', 'copycat', 'mefirst', 'photongeyser', 'shellsidearm', 'terablast'].includes(move.id) ||
(this.gen === 5 && move.id === 'naturepower')
) {
minAtk = false;
}
}
return { minAtk, minSpe };
}
getNickname(set: Dex.PokemonSet) {
return set.name || this.dex.species.get(set.species).baseSpecies || '';
}
canHyperTrain(set: Dex.PokemonSet) {
let format: string = this.format;
if (this.gen < 7 || format === 'gen7hiddentype') return false;
if ((set.level || this.defaultLevel) === 100) return true;
if ((set.level || this.defaultLevel) >= 50 && this.defaultLevel === 50) return true;
return false;
}
getHPIVs(hpType: Dex.TypeName | null) {
switch (hpType) {
case 'Dark':
return ['111111'];
case 'Dragon':
return ['011111', '101111', '110111'];
case 'Ice':
return ['010111', '100111', '111110'];
case 'Psychic':
return ['011110', '101110', '110110'];
case 'Electric':
return ['010110', '100110', '111011'];
case 'Grass':
return ['011011', '101011', '110011'];
case 'Water':
return ['100011', '111010'];
case 'Fire':
return ['101010', '110010'];
case 'Steel':
return ['100010', '111101'];
case 'Ghost':
return ['101101', '110101'];
case 'Bug':
return ['100101', '111100', '101100'];
case 'Rock':
return ['001100', '110100', '100100'];
case 'Ground':
return ['000100', '111001', '101001'];
case 'Poison':
return ['001001', '110001', '100001'];
case 'Flying':
return ['000001', '111000', '101000'];
case 'Fighting':
return ['001000', '110000', '100000'];
default:
return null;
}
}
getStat(stat: StatName, set: Dex.PokemonSet, ivOverride: number, evOverride?: number, natureOverride?: number) {
const supportsEVs = !this.isLetsGo;
const supportsAVs = !supportsEVs;
// do this after setting set.evs because it's assumed to exist
// after getStat is run
const species = this.dex.species.get(set.species);
if (!species.exists) return 0;
const level = set.level || this.defaultLevel;
const baseStat = species.baseStats[stat];
const iv = ivOverride;
const ev = evOverride ?? set.evs?.[stat] ?? (this.gen > 2 ? 0 : 252);
if (stat === 'hp') {
if (baseStat === 1) return 1;
if (!supportsEVs) return Math.trunc(Math.trunc(2 * baseStat + iv + 100) * level / 100 + 10) + (supportsAVs ? ev : 0);
return Math.trunc(Math.trunc(2 * baseStat + iv + Math.trunc(ev / 4) + 100) * level / 100 + 10);
}
let val = Math.trunc(Math.trunc(2 * baseStat + iv + Math.trunc(ev / 4)) * level / 100 + 5);
if (!supportsEVs) {
val = Math.trunc(Math.trunc(2 * baseStat + iv) * level / 100 + 5);
}
if (natureOverride) {
val *= natureOverride;
} else if (BattleNatures[set.nature!]?.plus === stat) {
val *= 1.1;
} else if (BattleNatures[set.nature!]?.minus === stat) {
val *= 0.9;
}
if (!supportsEVs) {
const friendshipValue = Math.trunc((70 / 255 / 10 + 1) * 100);
val = Math.trunc(val) * friendshipValue / 100 + (supportsAVs ? ev : 0);
}
return Math.trunc(val);
}
export(compat?: boolean) {
return Teams.export(this.sets, this.dex, !compat);
}
import(value: string) {
this.sets = Teams.import(value);
this.save();
}
getTypeWeakness(type: Dex.TypeName, attackType: Dex.TypeName): 0 | 0.5 | 1 | 2 {
const weaknessType = this.dex.types.get(type).damageTaken?.[attackType];
if (weaknessType === Dex.IMMUNE) return 0;
if (weaknessType === Dex.RESIST) return 0.5;
if (weaknessType === Dex.WEAK) return 2;
return 1;
}
getWeakness(types: readonly Dex.TypeName[], abilityid: ID, attackType: Dex.TypeName): number {
if (attackType === 'Ground' && abilityid === 'levitate') return 0;
if (attackType === 'Water' && abilityid === 'dryskin') return 0;
if (attackType === 'Fire' && abilityid === 'flashfire') return 0;
if (attackType === 'Electric' && abilityid === 'lightningrod' && this.gen >= 5) return 0;
if (attackType === 'Grass' && abilityid === 'sapsipper') return 0;
if (attackType === 'Electric' && abilityid === 'motordrive') return 0;
if (attackType === 'Water' && abilityid === 'stormdrain' && this.gen >= 5) return 0;
if (attackType === 'Electric' && abilityid === 'voltabsorb') return 0;
if (attackType === 'Water' && abilityid === 'waterabsorb') return 0;
if (attackType === 'Ground' && abilityid === 'eartheater') return 0;
if (attackType === 'Fire' && abilityid === 'wellbakedbody') return 0;
if (attackType === 'Fire' && abilityid === 'primordialsea') return 0;
if (attackType === 'Water' && abilityid === 'desolateland') return 0;
if (abilityid === 'wonderguard') {
for (const type of types) {
if (this.getTypeWeakness(type, attackType) <= 1) return 0;
}
}
let factor = 1;
if ((attackType === 'Fire' || attackType === 'Ice') && abilityid === 'thickfat') factor *= 0.5;
if (attackType === 'Fire' && abilityid === 'waterbubble') factor *= 0.5;
if (attackType === 'Fire' && abilityid === 'heatproof') factor *= 0.5;
if (attackType === 'Ghost' && abilityid === 'purifyingsalt') factor *= 0.5;
if (attackType === 'Fire' && abilityid === 'fluffy') factor *= 2;
if ((attackType === 'Electric' || attackType === 'Rock' || attackType === 'Ice') && abilityid === 'deltastream') {
factor *= 0.5;
}
for (const type of types) {
factor *= this.getTypeWeakness(type, attackType);
}
return factor;
}
pokemonDefensiveCoverage(set: Dex.PokemonSet) {
const coverage: Record<string, number> = {};
const species = this.dex.species.get(set.species);
const abilityid = toID(set.ability);
for (const type of this.dex.types.names()) {
coverage[type] = this.getWeakness(species.types, abilityid, type);
}
return coverage as Record<Dex.TypeName, number>;
}
teamDefensiveCoverage() {
type Counter = { type: Dex.TypeName, resists: number, neutrals: number, weaknesses: number };
const counters: Record<Dex.TypeName, Counter> = {} as any;
for (const type of this.dex.types.names()) {
counters[type] = {
type,
resists: 0,
neutrals: 0,
weaknesses: 0,
};
}
for (const set of this.sets) {
const coverage = this.pokemonDefensiveCoverage(set);
for (const [type, value] of Object.entries(coverage) as [Dex.TypeName, number][]) {
if (value < 1) {
counters[type].resists++;
} else if (value === 1) {
counters[type].neutrals++;
} else {
counters[type].weaknesses++;
}
}
}
return counters;
}
getDefaultAbility(set: Dex.PokemonSet) {
if (this.gen < 3 || this.isLetsGo || this.formeLegality === 'custom') return set.ability;
const species = this.dex.species.get(set.species);
if (this.formeLegality === 'hackmons') {
// TODO: support gen 9 hackmons forme legality more completely than this
if (this.gen < 9 || species.baseSpecies !== 'Xerneas') return set.ability;
// falls through to final return statement
} else if (this.abilityLegality === 'hackmons') {
if (!species.battleOnly) return set.ability;
if (species.requiredItems.length || species.baseSpecies === 'Meloetta') return set.ability;
// battle only species only ever have one ability
// if they don't have a required item and aren't Meloetta, they change formes with that ability
// so it's forced, even in AAA
return species.abilities[0];
}
const abilities = Object.values(species.abilities);
if (abilities.length === 1) return abilities[0];
if (set.ability && abilities.includes(set.ability)) return set.ability;
return undefined;
}
getDefaultItem(speciesName: string) {
const species = this.dex.species.get(speciesName);
let items = species.requiredItems;
if (this.gen !== 7 && !this.isNatDex) {
// Require plates on Arceus when Z crystals don't exist
items = items.filter(i => !i.endsWith('ium Z'));
}
if (items.length === 1) {
if (this.formeLegality === 'normal' ||
this.formeLegality === 'hackmons' && this.gen === 9 && species.battleOnly &&
!species.isMega && !species.isPrimal && species.name !== 'Necrozma-Ultra') {
return items[0];
}
}
return undefined;
}
save() {
this.team.packedTeam = Teams.pack(this.sets);
this.lastPackedTeam = this.team.packedTeam;
this.team.iconCache = null;
}
/** undefined: loading, null: unavailable */
static sampleSets: { [formatid: string]: SampleSetsTable | null } = {};
// not static for complicated reasons. either way leads to an obscure
// race condition if fetchSampleSets is called simultaneously from
// different TeamEditorState instances, but this way just means two
// network requests rather than the UI getting out of sync.
_sampleSetPromises: Record<string, Promise<void>> = {};
fetchSampleSets(formatid: ID) {
if (formatid in TeamEditorState.sampleSets) return;
if (formatid.length <= 4) {
TeamEditorState.sampleSets[formatid] = null;
return;
}
if (!(formatid in this._sampleSetPromises)) {
this._sampleSetPromises[formatid] = Net(
`https://${Config.routes.client}/data/sets/${formatid}.json`
).get().then(json => {
const data = JSON.parse(json);
TeamEditorState.sampleSets[formatid] = data;
this.update();
}).catch(() => {
TeamEditorState.sampleSets[formatid] = null;
});
}
}
/** returns null if sample sets aren't done loading */
getSampleSets(set: Dex.PokemonSet): string[] | null {
const d = TeamEditorState.sampleSets[this.format];
if (d === undefined) {
this.fetchSampleSets(this.format);
return null;
}
if (!d?.dex) return [];
const speciesid = toID(set.species);
const all = {
...d.dex[set.species],
...d.dex[speciesid],
...d.stats?.[set.species],
...d.stats?.[speciesid],
};
return Object.keys(all);
}
/** returns null if no boxes exist, empty array if no sets for this species */
getUserSets(set: Dex.PokemonSet): { [setName: string]: Dex.PokemonSet } | null {
if (!this.userSetsCache[this.format]) {
const userSets: { [species: string]: { [setName: string]: Dex.PokemonSet } } = {};
for (const team of window.PS?.teams.list || []) {
if (team.format !== this.format || !team.isBox) continue;
const setList = Teams.unpack(team.packedTeam);
const duplicateNameIndices: Record<string, number> = {};
for (const boxSet of setList) {
let name = boxSet.name || boxSet.species;
if (duplicateNameIndices[name]) {
name += ` ${duplicateNameIndices[name]}`;
}
duplicateNameIndices[name] = (duplicateNameIndices[name] || 0) + 1;
userSets[boxSet.species] ??= {};
userSets[boxSet.species][name] = boxSet;
}
}
this.userSetsCache[this.format] = userSets;
}
const cachedSets = this.userSetsCache[this.format];
if (Object.keys(cachedSets).length === 0) return null;
return cachedSets[set.species] || {};
}
static renderClipboard(cancelClipboard: () => void) {
if (!TeamEditorState.clipboard) return null;
const renderSet = (set: Dex.PokemonSet) => <div class="set">
<small>
<PSIcon pokemon={set} /> {set.name || set.species}
{set.ability && ` [${set.ability}]`}{set.item && ` @ ${set.item}`}
{} - {set.moves.join(' / ') || '(No moves)'}
</small>
</div>;
const renderTeam = (team: Team, sets: Dex.PokemonSet[]) => <div class="set"><small>
<strong>{team.name}</strong><br />
{sets.map(set => <PSIcon pokemon={set} />)}
</small></div>;
return <div class="infobox">
Clipboard
{Object.values(TeamEditorState.clipboard.teams || {})?.map(clipboardTeam => (
clipboardTeam.entire ? (
renderTeam(clipboardTeam.team, Object.values(clipboardTeam.sets))
) : (
Object.values(clipboardTeam.sets).map(set => renderSet(set))
)
))}
{TeamEditorState.clipboard.otherSets?.map(set => renderSet(set))}
<button class="button" onClick={cancelClipboard}>
<i class="fa fa-times" aria-hidden></i> Cancel
</button>
</div>;
}
}
export class TeamEditor extends preact.Component<{
team: Team, narrow?: boolean, onChange?: () => void, readOnly?: boolean,
children?: preact.ComponentChildren, resources?: preact.ComponentChildren,
}> {
wizard = true;
editor!: TeamEditorState;
setTab = (ev: Event) => {
const target = ev.currentTarget as HTMLButtonElement;
const wizard = target.value === 'wizard';
this.wizard = wizard;
this.forceUpdate();
};
static probablyMobile() {
return document.body.offsetWidth < 500;
}
renderDefensiveCoverage() {
const { editor } = this;
if (editor.team.isBox) return null;
if (!editor.sets.length) return null;
const counters = Object.values(editor.teamDefensiveCoverage());
PSUtils.sortBy(counters, counter => [counter.resists, -counter.weaknesses]);
const good = [], medium = [], bad = [];
const renderTypeDefensive = (counter: typeof counters[number]) => (
<tr>
<th>{counter.type}</th>
<td>{counter.resists} <small class="gray">resist</small></td>
<td>{counter.weaknesses} <small class="gray">weak</small></td>
</tr>
);
for (const counter of counters) {
if (counter.resists > 0) {
good.push(renderTypeDefensive(counter));
} else if (counter.weaknesses <= 0) {
medium.push(renderTypeDefensive(counter));
} else {
bad.push(renderTypeDefensive(counter));
}
}
return <details class="details">
<summary>
<strong>Defensive coverage</strong>
<table class="details-preview table">
{bad}
<tr><td colSpan={3}><span class="details-preview ilink"><small>See all</small></span></td></tr>
</table>
</summary>
<table class="table">{bad}{medium}{good}</table>
</details>;
}
cancelClipboard = () => {
TeamEditorState.clipboard = null;
this.forceUpdate();
};
update = () => {
this.forceUpdate();
};
override render() {
if (!this.editor) {
this.editor = new TeamEditorState(this.props.team);
this.editor.subscribe(() => {
this.forceUpdate();
});
}
const editor = this.editor;
window.editor = editor; // debug
editor.updateTeam(!!this.props.readOnly);
editor.narrow = this.props.narrow ?? document.body.offsetWidth < 500;
if (this.props.team.format !== editor.format) {
editor.setFormat(this.props.team.format);
}
return <div class="teameditor">
<ul class="tabbar">
<li><button onClick={this.setTab} value="wizard" class={`button${this.wizard ? ' cur' : ''}`}>
Wizard
</button></li>
<li><button onClick={this.setTab} value="import" class={`button${!this.wizard ? ' cur' : ''}`}>
Import/Export
</button></li>
</ul>
{TeamEditorState.renderClipboard(this.cancelClipboard)}
{this.wizard ? (
<TeamWizard editor={editor} onChange={this.props.onChange} onUpdate={this.update} />
) : (
<TeamTextbox editor={editor} onChange={this.props.onChange} onUpdate={this.update} />
)}
{!this.editor.innerFocus && <>
{this.props.children}
<div class="team-resources">
<br /><hr /><br />
{this.renderDefensiveCoverage()}
{this.props.resources}
</div>
</>}
</div>;
}
}
class TeamTextbox extends preact.Component<{
editor: TeamEditorState,
onChange?: () => void, onUpdate?: () => void,
}> {
static EMPTY_PROMISE = Promise.resolve(null);
editor!: TeamEditorState;
setInfo: {
species: string,
bottomY: number,
index: number,
}[] = [];
textbox: HTMLTextAreaElement = null!;
heightTester: HTMLTextAreaElement = null!;
compat = false;
/** we changed the set but are delaying updates until the selection form is closed */
setDirty = false;
windowing = true;
selection: {
setIndex: number,
type: SelectionType | null,
typeIndex: number,
lineRange: [number, number] | null,
} | null = null;
innerFocus: {
offsetY: number | null,
setIndex: number,
type: SelectionType,
/** i.e. which move is this */
typeIndex: number,
range: [number, number],
/** if you edit, you'll change the range end, so it needs to be updated with this in mind */
rangeEndChar: string,
} | null = null;
getYAt(index: number, fullLine?: boolean) {
if (index < 0) return 10;
if (index === 0) return 31;
const newValue = this.textbox.value.slice(0, index);
this.heightTester.value = fullLine && !newValue.endsWith('\n') ? newValue + '\n' : newValue;
return this.heightTester.scrollHeight;
}
input = () => {
this.updateText();
this.save();
};
keyUp = () => this.updateText(true);
contextMenu = (ev: MouseEvent) => {
if (!ev.shiftKey) {
const hadInnerFocus = this.innerFocus?.range[1];
this.openInnerFocus();
if (hadInnerFocus !== this.innerFocus?.range[1]) {
ev.preventDefault();
ev.stopImmediatePropagation();
}
}
};
openInnerFocus() {
const oldRange = this.selection?.lineRange;
this.updateText(true, true);
if (this.selection) {
// this shouldn't actually update anything, so the reference comparison is enough
if (this.selection.lineRange === oldRange) return !!this.innerFocus;
if (this.textbox.selectionStart === this.textbox.selectionEnd) {
const range = this.getSelectionTypeRange();
if (range) this.textbox.setSelectionRange(range[0], range[1]);
}
}
return !!this.innerFocus;
}
keyDown = (ev: KeyboardEvent) => {
const editor = this.editor;
switch (ev.keyCode) {
case 27: // escape
case 8: // backspace
if (this.innerFocus) {
const atStart = (this.innerFocus.range[0] === this.textbox.selectionStart &&
this.innerFocus.range[0] === this.textbox.selectionEnd);
if (ev.keyCode === 27 || atStart) {
if (editor.search.removeFilter()) {
editor.setSearchValue(this.getInnerFocusValue());
this.resetScroll();
this.forceUpdate();
ev.stopImmediatePropagation();
ev.preventDefault();
} else if (this.closeMenu()) {
ev.stopImmediatePropagation();
ev.preventDefault();
}
}
}
break;
case 38: // up
if (this.innerFocus) {
editor.upSearchValue();
const resultsUp = this.base!.querySelector('.searchresults');
if (resultsUp) {
resultsUp.scrollTop = Math.max(0, editor.searchIndex * 33 - Math.trunc((window.innerHeight - 100) * 0.4));
}
this.forceUpdate();
ev.preventDefault();
}
break;
case 40: // down
if (this.innerFocus) {
editor.downSearchValue();
const resultsDown = this.base!.querySelector('.searchresults');
if (resultsDown) {
resultsDown.scrollTop = Math.max(0, editor.searchIndex * 33 - Math.trunc((window.innerHeight - 100) * 0.4));
}
this.forceUpdate();
ev.preventDefault();
}
break;
case 9: // tab
case 13: // enter
if (ev.keyCode === 13 && ev.shiftKey) return;
if (ev.altKey || ev.metaKey) return;
if (!this.innerFocus) {
if (this.maybeReplaceLine()) {
// do nothing else
} else if (
this.textbox.selectionStart === this.textbox.value.length &&
(this.textbox.value.endsWith('\n\n') || !this.textbox.value)
) {
this.addPokemon();
} else if (!this.openInnerFocus()) {
break;
}
ev.stopImmediatePropagation();
ev.preventDefault();
} else {
const result = this.editor.selectSearchValue();
if (result !== null) {
const [name, moveSlot] = result.split('|');
this.selectResult(this.innerFocus.type, name, moveSlot);
} else {
this.replaceNoFocus('', this.innerFocus.range[0], this.innerFocus.range[1]);
this.editor.setSearchValue('');
this.forceUpdate();
}
this.resetScroll();
ev.stopImmediatePropagation();
ev.preventDefault();
}
break;
case 80: // p
if (ev.metaKey) {
window.PS?.alert(editor.export(this.compat));
ev.stopImmediatePropagation();
ev.preventDefault();
break;
}
}
};
maybeReplaceLine = () => {
if (this.textbox.selectionStart !== this.textbox.selectionEnd) return;
const current = this.textbox.selectionEnd;
const lineStart = this.textbox.value.lastIndexOf('\n', current) + 1;
const value = this.textbox.value.slice(lineStart, current);
const pokepaste = /^https?:\/\/pokepast.es\/([a-z0-9]+)(?:\/.*)?$/.exec(value)?.[1];
if (pokepaste) {
this.editor.fetching = true;
Net(`https://pokepast.es/${pokepaste}/json`).get().then(json => {
const paste = JSON.parse(json);
const pasteTxt = paste.paste.replace(/\r\n/g, '\n');
if (this.textbox) {
// make sure it's still there:
const valueIndex = this.textbox.value.indexOf(value);
this.replace(paste.paste.replace(/\r\n/g, '\n'), valueIndex, valueIndex + value.length);
} else {
this.editor.import(pasteTxt);
this.props.onChange?.();
}
const notes = paste["notes"] as string;
if (notes.startsWith("Format: ")) {
const formatid = toID(notes.slice(8));
this.editor.setFormat(formatid);
}
const title = paste["title"] as string;
if (title && !title.startsWith('Untitled')) {
this.editor.team.name = title.replace(/[|\\/]/g, '');
}
this.editor.fetching = false;
this.props.onUpdate?.();
});
return true;
}
return false;
};
getInnerFocusValue() {
if (!this.innerFocus) return '';
return this.textbox.value.slice(this.innerFocus.range[0], this.innerFocus.range[1]);
}
clearInnerFocus() {
if (this.innerFocus) {
if (this.innerFocus.type === 'pokemon') {
const value = this.getInnerFocusValue();
if (!toID(value)) {
this.replaceNoFocus(this.editor.originalSpecies || '', this.innerFocus.range[0], this.innerFocus.range[1]);
}
}
this.innerFocus = null;
}
}
closeMenu = () => {
if (this.innerFocus) {
this.clearInnerFocus();
if (this.setDirty) {
this.updateText();
this.save();
} else {
this.forceUpdate();
}
this.textbox.focus();
return true;
}
return false;
};
updateText = (noTextChange?: boolean, autoSelect?: boolean | SelectionType) => {
const textbox = this.textbox;
let value = textbox.value;
let selectionStart = textbox.selectionStart || 0;
let selectionEnd = textbox.selectionEnd || 0;
if (this.innerFocus) {
if (!noTextChange) {
let lineEnd = this.textbox.value.indexOf('\n', this.innerFocus.range[0]);
if (lineEnd < 0) lineEnd = this.textbox.value.length;
const line = this.textbox.value.slice(this.innerFocus.range[0], lineEnd);
if (this.innerFocus.rangeEndChar) {
const index = line.indexOf(this.innerFocus.rangeEndChar);
if (index >= 0) lineEnd = this.innerFocus.range[0] + index;
}
this.innerFocus.range[1] = lineEnd;
}
const [start, end] = this.innerFocus.range;
if (selectionStart >= start && selectionStart <= end && selectionEnd >= start && selectionEnd <= end) {
if (!noTextChange) {
this.updateSearch();
this.setDirty = true;
}
return;
}
this.clearInnerFocus();
value = textbox.value;
selectionStart = textbox.selectionStart || 0;
selectionEnd = textbox.selectionEnd || 0;
}
if (this.setDirty) {
this.setDirty = false;
noTextChange = false;
}
this.heightTester.style.width = `${textbox.offsetWidth}px`;
/** index of `value` that we've parsed to */
let index = 0;
/** for the set we're currently parsing */
let setIndex: number | null = null;
let nextSetIndex = 0;
if (!noTextChange) this.setInfo = [];
this.selection = null;
while (index < value.length) {
let nlIndex = value.indexOf('\n', index);
if (nlIndex < 0) nlIndex = value.length;
const line = value.slice(index, nlIndex);
if (!line.trim()) {
setIndex = null;
index = nlIndex + 1;
continue;
}
if (setIndex === null && index && !noTextChange && this.setInfo.length) {
this.setInfo[this.setInfo.length - 1].bottomY = this.getYAt(index - 1);
}
if (setIndex === null) {
if (!noTextChange) {
const atIndex = line.indexOf('@');
let species = atIndex >= 0 ? line.slice(0, atIndex).trim() : line.trim();
if (species.endsWith(' (M)') || species.endsWith(' (F)')) {
species = species.slice(0, -4);
}
if (species.endsWith(')')) {
const parenIndex = species.lastIndexOf(' (');
if (parenIndex >= 0) {
species = species.slice(parenIndex + 2, -1);
}
}
this.setInfo.push({
species,
bottomY: -1,
index,
});
}
setIndex = nextSetIndex;
nextSetIndex++;
}
const selectionEndCutoff = (selectionStart === selectionEnd ? nlIndex : nlIndex + 1);
let start = index, end = index + line.length;
if (index <= selectionStart && selectionEnd <= selectionEndCutoff) {
// both ends within range
let type: SelectionType | null = null;
const lcLine = line.toLowerCase().trim();
if (lcLine.startsWith('ability:')) {
type = 'ability';
} else if (lcLine.startsWith('-')) {
type = 'move';
} else if (
!lcLine || lcLine.startsWith('level:') || lcLine.startsWith('gender:') ||
(lcLine + ':').startsWith('shiny:') || (lcLine + ':').startsWith('gigantamax:') ||
lcLine.startsWith('tera type:') || lcLine.startsWith('dynamax level:')
) {
type = 'details';
} else if (
lcLine.startsWith('ivs:') || lcLine.startsWith('evs:') ||
lcLine.endsWith(' nature')
) {
type = 'stats';
} else {
type = 'pokemon';
const atIndex = line.indexOf('@');
if (atIndex >= 0) {
if (selectionStart > index + atIndex) {
type = 'item';
start = index + atIndex + 1;
} else {
end = index + atIndex;
if (line.charAt(atIndex - 1) === ']' || line.charAt(atIndex - 2) === ']') {
type = 'ability';
}
}
}
}
if (typeof autoSelect === 'string') autoSelect = autoSelect === type;
this.selection = {
setIndex, type, lineRange: [start, end], typeIndex: 0,
};
if (autoSelect) this.engageFocus();
}
index = nlIndex + 1;
}
if (!noTextChange) {
const end = value.endsWith('\n\n') ? value.length - 1 : value.length;
const bottomY = this.getYAt(end, true);
if (this.setInfo.length) {
this.setInfo[this.setInfo.length - 1].bottomY = bottomY;
}
textbox.style.height = `${bottomY + 100}px`;
}
this.forceUpdate();
};
engageFocus(focus?: this['innerFocus']) {
if (this.innerFocus && !focus) return;
const editor = this.editor;
if (editor.readonly) return;
if (!focus) {
if (!this.selection?.type) return;
const range = this.getSelectionTypeRange();
if (!range) return;
const { type, setIndex } = this.selection;
let rangeEndChar = this.textbox.value.charAt(range[1]);
if (rangeEndChar === ' ') rangeEndChar += this.textbox.value.charAt(range[1] + 1);
focus = {
offsetY: this.getYAt(range[0]),
setIndex,
type,
typeIndex: this.selection.typeIndex,
range,
rangeEndChar,
};
}
this.innerFocus = focus;
if (focus.type === 'details' || focus.type === 'stats') {
this.forceUpdate();
return;
}
const value = this.textbox.value.slice(focus.range[0], focus.range[1]);
editor.setSearchType(focus.type, focus.setIndex, value);
this.resetScroll();
this.textbox.setSelectionRange(focus.range[0], focus.range[1]);
this.forceUpdate();
}
updateSearch() {
if (!this.innerFocus) return;
const { range } = this.innerFocus;
const editor = this.editor;
const value = this.textbox.value.slice(range[0], range[1]);
editor.setSearchValue(value);
this.resetScroll();
this.forceUpdate();
}
selectResult = (type: string | null, name: string, moveSlot?: string) => {
if (type === null) {
this.resetScroll();
this.forceUpdate();
} else if (!type) {
this.changeSet(this.innerFocus!.type, '');
} else {
this.changeSet(type as SelectionType, name, moveSlot);
}
};
getSelectionTypeRange(): [number, number] | null {
const selection = this.selection;
if (!selection?.lineRange) return null;
let [start, end] = selection.lineRange;
let lcLine = this.textbox.value.slice(start, end).toLowerCase();
if (lcLine.endsWith(' ')) {
end -= 2;
lcLine = lcLine.slice(0, -2);
}
switch (selection.type) {
case 'pokemon': {
// let atIndex = lcLine.lastIndexOf('@');
// if (atIndex >= 0) {
// if (lcLine.charAt(atIndex - 1) === ' ') atIndex--;
// lcLine = lcLine.slice(0, atIndex);
// end = start + atIndex;
// }
if (lcLine.endsWith(' ')) {
lcLine = lcLine.slice(0, -1);
end--;
}
if (lcLine.endsWith(' (m)') || lcLine.endsWith(' (f)')) {
lcLine = lcLine.slice(0, -4);
end -= 4;
}
if (lcLine.endsWith(')')) {
const parenIndex = lcLine.lastIndexOf(' (');
if (parenIndex >= 0) {
start = start + parenIndex + 2;
end--;
}
}
return [start, end];
}
case 'item': {
// let atIndex = lcLine.lastIndexOf('@');
// if (atIndex < 0) return null;
// if (lcLine.charAt(atIndex + 1) === ' ') atIndex++;
// return { start: start + atIndex + 1, end };
if (lcLine.startsWith(' ')) start++;
return [start, end];
}
case 'ability': {
if (lcLine.startsWith('[')) {
start++;
if (lcLine.endsWith(' ')) {
end--;
lcLine = lcLine.slice(0, -1);
}
if (lcLine.endsWith(']')) {
end--;
}
return [start, end];
}
if (!lcLine.startsWith('ability:')) return null;
start += lcLine.startsWith('ability: ') ? 9 : 8;
return [start, end];
}
case 'move': {
if (!lcLine.startsWith('-')) return null;
start += lcLine.startsWith('- ') ? 2 : 1;
return [start, end];
}
}
return [start, end];
}
changeSet(type: SelectionType, name: string, moveSlot?: string) {
const focus = this.innerFocus;
if (!focus) return;
if (type === focus.type && type !== 'pokemon') {
this.replace(name, focus.range[0], focus.range[1]);
this.updateText(false, true);
return;
}
switch (type) {
case 'pokemon': {
const set = this.editor.sets[focus.setIndex] ||= {
species: '',
moves: [],
};
this.editor.changeSpecies(set, name);
this.replaceSet(focus.setIndex);
this.updateText(false, true);
break;
}
case 'ability': {
this.editor.sets[focus.setIndex].ability = name;
this.replaceSet(focus.setIndex);
this.updateText(false, true);
break;
}
}
}
getSetRange(index: number) {
if (!this.setInfo[index]) {
if (this.innerFocus?.setIndex === index) {
return this.innerFocus.range;
}
return [this.textbox.value.length, this.textbox.value.length];
}
const start = this.setInfo[index].index;
const end = this.setInfo[index + 1].index;
return [start, end];
}
changeCompat = (ev: Event) => {
const checkbox = ev.currentTarget as HTMLInputElement;
this.compat = checkbox.checked;
this.editor.import(this.textbox.value);
this.textbox.value = this.editor.export(this.compat);
// this.textbox.select();
// document.execCommand('insertText', false, this.editor.export(this.compat));
this.updateText();
};
replaceSet(index: number) {
const editor = this.editor;
const { team } = editor;
if (!team) return;
let newText = Teams.exportSet(editor.sets[index], editor.dex, !this.compat);
const [start, end] = this.getSetRange(index);
if (start && start === this.textbox.value.length && !this.textbox.value.endsWith('\n\n')) {
newText = (this.textbox.value.endsWith('\n') ? '\n' : '\n\n') + newText;
}
this.replaceNoFocus(newText, start, end, start + newText.length);
// we won't do a full update but we do need to update where the end is,
// for future updates
if (!this.setInfo[index]) {
this.updateText();
this.save();
} else {
if (this.setInfo[index + 1]) {
this.setInfo[index + 1].index = start + newText.length;
}
// others don't need to be updated;
// we'll do a full update next time we focus the textbox
this.setDirty = true;
}
}
replace(text: string, start: number, end: number, selectionStart = start, selectionEnd = start + text.length) {
const textbox = this.textbox;
// const value = textbox.value;
// textbox.value = value.slice(0, start) + text + value.slice(end);
textbox.focus();
textbox.setSelectionRange(start, end);
document.execCommand('insertText', false, text);
// textbox.setSelectionRange(selectionStart, selectionEnd);
this.save();
}
replaceNoFocus(text: string, start: number, end: number, selectionStart = start, selectionEnd = start + text.length) {
const textbox = this.textbox;
const value = textbox.value;
textbox.value = value.slice(0, start) + text + value.slice(end);
textbox.setSelectionRange(selectionStart, selectionEnd);
this.save();
}
save() {
this.editor.import(this.textbox.value);
this.props.onChange?.();
}
override componentDidMount() {
this.textbox = this.base!.getElementsByClassName('teamtextbox')[0] as HTMLTextAreaElement;
this.heightTester = this.base!.getElementsByClassName('heighttester')[0] as HTMLTextAreaElement;
this.editor = this.props.editor;
const exportedTeam = this.editor.export(this.compat);
this.textbox.value = exportedTeam;
this.updateText();
setTimeout(() => this.updateText());
}
override componentWillUnmount() {
this.textbox = null!;
this.heightTester = null!;
}
clickDetails = (ev: Event) => {
const target = ev.currentTarget as HTMLButtonElement;
const i = parseInt(target.value || '0');
if (this.innerFocus?.type === target.name) {
this.innerFocus = null;
this.forceUpdate();
return;
}
this.engageFocus({
offsetY: null,
setIndex: i,
type: target.name as SelectionType,
typeIndex: 0,
range: [0, 0],
rangeEndChar: '',
});
};
addPokemon = () => {
if (this.textbox.value && !this.textbox.value.endsWith('\n\n')) {
this.textbox.value += this.textbox.value.endsWith('\n') ? '\n' : '\n\n';
}
const end = this.textbox.value === '\n\n' ? 0 : this.textbox.value.length;
this.textbox.setSelectionRange(end, end);
this.textbox.focus();
this.engageFocus({
offsetY: this.getYAt(end, true),
setIndex: this.setInfo.length,
type: 'pokemon',
typeIndex: 0,
range: [end, end],
rangeEndChar: '@',
});
};
scrollResults = (ev: Event) => {
if (!(ev.currentTarget as HTMLElement).scrollTop) return;
this.windowing = false;
if (document.documentElement.clientWidth === document.documentElement.scrollWidth) {
(ev.currentTarget as any).scrollIntoViewIfNeeded?.();
}
this.forceUpdate();
};
resetScroll() {
this.windowing = true;
const searchResults = this.base!.querySelector('.searchresults');
if (searchResults) searchResults.scrollTop = 0;
}
windowResults() {
if (this.windowing) {
return Math.ceil(window.innerHeight / 33);
}
return null;
}
renderDetails(set: Dex.PokemonSet, i: number) {
const editor = this.editor;
const species = editor.dex.species.get(set.species);
const GenderChart = {
'M': 'Male',
'F': 'Female',
'N': '\u2014', // em dash
};
const gender = GenderChart[(set.gender || species.gender || 'N') as 'N'];
return <button class="textbox setdetails" name="details" value={i} onClick={this.clickDetails}>
<span class="detailcell">
<label>Level</label>{set.level || editor.defaultLevel}
</span>
<span class="detailcell">
<label>Shiny</label>{set.shiny ? 'Yes' : '\u2014'}
</span>
{editor.gen === 9 ? (
<span class="detailcell">
<label>Tera</label><PSIcon type={set.teraType || species.requiredTeraType || species.types[0]} />
</span>
) : editor.hpTypeMatters(set) ? (
<span class="detailcell">
<label>H. Power</label><PSIcon type={editor.getHPType(set)} />
</span>
) : (
<span class="detailcell">
<label>Gender</label>{gender}
</span>
)}
</button>;
}
renderStats(set: Dex.PokemonSet, i: number) {
const editor = this.editor;
// stat cell
return <button class="textbox setstats" name="stats" value={i} onClick={this.clickDetails}>
{StatForm.renderStatGraph(set, editor)}
</button>;
}
handleSetChange = () => {
if (this.selection) {
this.replaceSet(this.selection.setIndex);
this.forceUpdate();
}
};
bottomY() {
return this.setInfo[this.setInfo.length - 1]?.bottomY ?? 8;
}
copyAll = (ev: Event) => {
this.textbox.select();
document.execCommand('copy');
const button = ev?.currentTarget as HTMLButtonElement;
if (button) {
button.innerHTML = '<i class="fa fa-check" aria-hidden="true"></i> Copied';
button.className += ' cur';
}
};
render() {
const editor = this.props.editor;
const statsDetailsOffset = editor.gen >= 3 ? 18 : -1;
return <div>
<p>
<button class="button" onClick={this.copyAll}>
<i class="fa fa-copy" aria-hidden></i> Copy
</button> {}
<label class="checkbox inline">
<input type="checkbox" name="compat" onChange={this.changeCompat} /> Old export format
</label>
</p>
<div class="teameditor-text">
<textarea
class="textbox teamtextbox" style={`padding-left:${editor.narrow ? '50px' : '100px'}`}
onInput={this.input} onContextMenu={this.contextMenu} onKeyUp={this.keyUp} onKeyDown={this.keyDown}
onClick={this.keyUp} onChange={this.maybeReplaceLine}
placeholder=" Paste exported teams, pokepaste URLs, or JSON here" readOnly={editor.readonly}
/>
<textarea
class="textbox teamtextbox heighttester" tabIndex={-1} aria-hidden
style={`padding-left:${editor.narrow ? '50px' : '100px'};visibility:hidden;left:-15px`}
/>
<div class="teamoverlays">
{this.setInfo.slice(0, -1).map(info =>
<hr style={`top:${info.bottomY - 18}px;pointer-events:none`} />
)}
{editor.canAdd() && !!this.setInfo.length && <hr style={`top:${this.bottomY() - 18}px`} />}
{this.setInfo.map((info, i) => {
if (!info.species) return null;
const set = editor.sets[i];
if (!set) return null;
const prevOffset = i === 0 ? 8 : this.setInfo[i - 1].bottomY;
const species = editor.dex.species.get(info.species);
const num = Dex.getPokemonIconNum(species.id);
if (!num) return null;
if (editor.narrow) {
return <div style={`top:${prevOffset + 1}px;left:5px;position:absolute;text-align:center;pointer-events:none`}>
<div><PSIcon pokemon={species.id} /></div>
{species.types.map(type => <div><PSIcon type={type} /></div>)}
<div><PSIcon item={set.item || null} /></div>
</div>;
}
return [<div
style={
`top:${prevOffset - 7}px;left:0;position:absolute;text-align:right;` +
`width:94px;padding:103px 5px 0 0;min-height:24px;pointer-events:none;` +
Dex.getTeambuilderSprite(set, editor.dex)
}
>
<div>{species.types.map(type => <PSIcon type={type} />)}<PSIcon item={set.item || null} /></div>
</div>, <div style={`top:${prevOffset + statsDetailsOffset}px;right:9px;position:absolute`}>
{this.renderStats(set, i)}
</div>, <div style={`top:${prevOffset + statsDetailsOffset}px;right:145px;position:absolute`}>
{this.renderDetails(set, i)}
</div>];
})}
{editor.canAdd() && !(this.innerFocus && this.innerFocus.setIndex >= this.setInfo.length) && (
<div style={`top:${this.bottomY() - 3}px;left:${editor.narrow ? 55 : 105}px;position:absolute`}>
<button class="button" onClick={this.addPokemon}>
<i class="fa fa-plus" aria-hidden></i> Add Pok&eacute;mon
</button>
</div>
)}
{this.innerFocus?.offsetY != null && (
<div
class={`teaminnertextbox teaminnertextbox-${this.innerFocus.type}`}
style={`top:${this.innerFocus.offsetY - 21}px;left:${editor.narrow ? 46 : 96}px;`}
></div>
)}
</div>
{this.innerFocus && (
<div
class="searchresults"
style={`top:${(this.setInfo[this.innerFocus.setIndex]?.bottomY ?? this.bottomY() + 50) - 12}px`}
onScroll={this.scrollResults}
>
<button class="button closesearch" onClick={this.closeMenu}>
{!editor.narrow && <kbd>Esc</kbd>} <i class="fa fa-times" aria-hidden></i> Close
</button>
{this.innerFocus.type === 'stats' ? (
<StatForm editor={editor} set={this.editor.sets[this.innerFocus.setIndex]} onChange={this.handleSetChange} />
) : this.innerFocus.type === 'details' ? (
<DetailsForm editor={editor} set={this.editor.sets[this.innerFocus.setIndex]} onChange={this.handleSetChange} />
) : (
<PSSearchResults
search={editor.search} resultIndex={editor.searchIndex}
windowing={this.windowResults()} onSelect={this.selectResult}
/>
)}
</div>
)}
</div>
</div>;
}
}
class TeamWizard extends preact.Component<{
editor: TeamEditorState, onChange?: () => void, onUpdate: () => void,
}> {
setSearchBox: string | null = null;
windowing = true;
setFocus = (ev: Event) => {
const { editor } = this.props;
if (editor.readonly) return;
const target = ev.currentTarget as HTMLButtonElement;
const [rawType, i] = (target.value || '').split('|');
const setIndex = parseInt(i);
const type = rawType as SelectionType;
if (!target.value || editor.innerFocus && editor.innerFocus.setIndex === setIndex && editor.innerFocus.type === type) {
this.changeFocus(null);
return;
}
this.changeFocus({
setIndex,
type,
});
};
deleteSet = (ev: Event) => {
const target = ev.currentTarget as HTMLButtonElement;
const i = parseInt(target.value);
const { editor } = this.props;
editor.deleteSet(i);
if (editor.innerFocus) {
this.changeFocus({
setIndex: editor.sets.length,
type: 'pokemon',
});
}
this.handleSetChange();
ev.preventDefault();
};
copySet = (ev: Event) => {
const target = ev.currentTarget as HTMLButtonElement;
const i = parseInt(target.value);
const { editor } = this.props;
editor.copySet(i);
editor.innerFocus = null;
this.props.onUpdate();
window.PS?.update();
ev.preventDefault();
};
undeleteSet = (ev: Event) => {
const { editor } = this.props;
const setIndex = editor.deletedSet?.index;
editor.undeleteSet();
if (editor.innerFocus && setIndex !== undefined) {
this.changeFocus({
setIndex,
type: 'pokemon',
});
}
this.handleSetChange();
ev.preventDefault();
};
pasteSet = (ev: Event) => {
const target = ev.currentTarget as HTMLButtonElement;
const i = parseInt(target.value);
const { editor } = this.props;
editor.pasteSet(i);
this.handleSetChange();
window.PS?.update();
ev.preventDefault();
};
moveSet = (ev: Event) => {
const target = ev.currentTarget as HTMLButtonElement;
const i = parseInt(target.value);
const { editor } = this.props;
editor.pasteSet(i, true);
this.handleSetChange();
ev.preventDefault();
};
changeFocus(focus: TeamEditorState['innerFocus']) {
const { editor } = this.props;
editor.innerFocus = focus;
if (!focus) {
this.props.onUpdate();
return;
}
const set = editor.sets[focus.setIndex];
if (focus.type === 'details') {
this.setSearchBox = set.name || '';
} else if (focus.type !== 'stats') {
let value;
if (focus.type === 'pokemon') value = set?.species || '';
else if (focus.type === 'item') value = set.item;
else if (focus.type === 'ability') value = set.ability;
editor.setSearchType(focus.type, focus.setIndex, value);
this.resetScroll();
this.setSearchBox = value || '';
}
this.props.onUpdate();
}
renderSet(set: Dex.PokemonSet | undefined, i: number) {
const { editor } = this.props;
const sprite = Dex.getTeambuilderSprite(set, editor.dex);
if (!set) {
return <div class="set-button">
<div style="text-align:right">
{editor.deletedSet ? (
<button onClick={this.undeleteSet} class="option"><i class="fa fa-undo" aria-hidden></i> Undo delete</button>
) : (
<button class="option" style="visibility:hidden"><i class="fa fa-trash" aria-hidden></i> Delete</button>
)}
</div>
<table>
<tr>
<td rowSpan={2} class="set-pokemon"><div class="border-collapse">
<button class="button button-first cur" onClick={this.setFocus} value={`pokemon|${i}`}>
<span class="sprite" style={sprite}><span class="sprite-inner">
<strong class="label">Pokemon</strong> {}
<em>(choose species)</em>
</span></span>
</button>
</div></td>
<td colSpan={2} class="set-details"></td>
<td rowSpan={2} class="set-moves"></td>
<td rowSpan={2} class="set-stats"></td>
</tr>
<tr>
<td class="set-ability"></td>
<td class="set-item"></td>
</tr>
</table>
</div>;
}
while (set.moves.length < 4) set.moves.push('');
const overfull = set.moves.length > 4 ? ' overfull' : '';
const cur = (t: SelectionType) => (
editor.readonly || (editor.innerFocus?.type === t && editor.innerFocus.setIndex === i) ? ' cur' : ''
);
const species = editor.dex.species.get(set.species);
const isCur = TeamEditorState.clipboard?.teams?.[editor.team.key]?.sets[i] ? ' cur' : '';
return <div class={`set-button${isCur}`}>
<div style="text-align:right">
<button class="option" onClick={this.copySet} value={i}>
<i class="fa fa-copy" aria-hidden></i> {
isCur ? "Deselect" :
TeamEditorState.clipboard ? "Add to clipboard" :
editor.readonly ? "Copy" :
"Copy/Move"
}
</button> {}
{!(TeamEditorState.clipboard || editor.readonly) && <button class="option" onClick={this.deleteSet} value={i}>
<i class="fa fa-trash" aria-hidden></i> Delete
</button>}
</div>
<table>
<tr>
<td rowSpan={2} class="set-pokemon"><div class="border-collapse">
<button class={`button button-first${cur('pokemon')}`} onClick={this.setFocus} value={`pokemon|${i}`}>
<span class="sprite" style={sprite}><span class="sprite-inner">
<strong class="label">Pokemon</strong> {}
{set.species}
</span></span>
</button>
</div></td>
<td colSpan={2} class="set-details"><div class="border-collapse">
<button class={`button button-middle${cur('details')}`} onClick={this.setFocus} value={`details|${i}`}>
<span class="detailcell">
<strong class="label">Types</strong> {}
{species.types.map(type => <div><PSIcon type={type} /></div>)}
</span>
<span class="detailcell">
<strong class="label">Level</strong> {}
{set.level || editor.defaultLevel}
{editor.narrow && set.shiny && <><br />
<img src={`${Dex.resourcePrefix}sprites/misc/shiny.png`} width={22} height={22} alt="Shiny" />
</>}
{!editor.narrow && set.gender && set.gender !== 'N' && <>
<br /><img
src={`${Dex.fxPrefix}gender-${set.gender.toLowerCase()}.png`} alt={set.gender} width="7" height="10" class="pixelated"
/>
</>}
</span>
{!!(!editor.narrow && (set.shiny || editor.gen >= 2)) && <span class="detailcell">
<strong class="label">Shiny</strong> {}
{set.shiny ? <img src={`${Dex.resourcePrefix}sprites/misc/shiny.png`} width={22} height={22} alt="Yes" /> : '\u2014'}
</span>}
{editor.gen === 9 && <span class="detailcell">
<strong class="label">Tera</strong> {}
<PSIcon type={set.teraType || species.requiredTeraType || species.types[0]} />
</span>}
{editor.hpTypeMatters(set) && <span class="detailcell">
<strong class="label">H.P.</strong> {}
<PSIcon type={editor.getHPType(set)} />
</span>}
</button>
</div></td>
<td rowSpan={2} class="set-moves"><div class="border-collapse">
<button class={`button button-middle${cur('move')}${overfull}`} onClick={this.setFocus} value={`move|${i}`}>
<strong class="label">Moves</strong> {}
{set.moves.map((move, mi) => <div>
{!editor.narrow && <small class="gray">&bull;</small>}
{mi >= 4 ? <span class="message-error">{move || (editor.narrow && '-') || ''}</span> : move || (editor.narrow && '-')}
</div>)}
{!set.moves.length && <em>(no moves)</em>}
</button>
</div></td>
<td rowSpan={2} class="set-stats"><div class="border-collapse">
<button class={`button button-last${cur('stats')}`} onClick={this.setFocus} value={`stats|${i}`}>
{StatForm.renderStatGraph(set, this.props.editor, true)}
</button>
</div></td>
</tr>
<tr>
<td class="set-ability"><div class="border-collapse">
<button class={`button button-middle${cur('ability')}`} onClick={this.setFocus} value={`ability|${i}`}>
{(editor.gen >= 3 || set.ability) && <>
<strong class="label">Ability</strong> {}
{(set.ability !== 'No Ability' && set.ability) ||
(!set.ability ? <em>(choose ability)</em> : <em>(no ability)</em>)}
</>}
</button>
</div></td>
<td class="set-item"><div class="border-collapse">
<button class={`button button-middle${cur('item')}`} onClick={this.setFocus} value={`item|${i}`}>
{(editor.gen >= 2 || set.item) && <>
{set.item && <PSIcon item={set.item} />}
<strong class="label">Item</strong> {}
{set.item || <em>(no item)</em>}
</>}
</button>
</div></td>
</tr>
</table>
<button class={`button set-nickname${cur('details')}`} onClick={this.setFocus} value={`details|${i}`}>
<strong class="label">Nickname</strong> {}
{editor.getNickname(set)}
</button>
</div>;
}
handleSetChange = () => {
this.props.editor.save();
this.props.onChange?.();
this.forceUpdate();
};
clearSearchBox() {
const searchBox = this.base!.querySelector<HTMLInputElement>('input[name=value]');
if (searchBox) {
searchBox.value = '';
if (!TeamEditor.probablyMobile()) searchBox.focus();
}
}
selectResult = (type: string | null, name: string, slot?: string, reverse?: boolean) => {
const { editor } = this.props;
this.clearSearchBox();
if (type === null) {
this.resetScroll();
this.forceUpdate();
} if (!type) {
editor.setSearchValue('');
this.resetScroll();
this.forceUpdate();
} else {
const setIndex = editor.innerFocus!.setIndex;
const set = (editor.sets[setIndex] ||= { species: '', moves: [] });
switch (type) {
case 'pokemon':
editor.changeSpecies(set, name);
this.changeFocus({
setIndex,
type: reverse ? 'details' : 'ability',
});
break;
case 'ability':
if (name === 'No Ability' && editor.gen <= 2) name = '';
set.ability = name;
this.changeFocus({
setIndex,
type: reverse ? 'pokemon' : 'item',
});
break;
case 'item':
set.item = name;
this.changeFocus({
setIndex,
type: reverse ? 'ability' : 'move',
});
break;
case 'move':
if (slot) {
// intentional; we're _removing_ from the slot
const i = parseInt(slot) - 1;
if (set.moves[i]) {
set.moves[i] = '';
// remove empty slots at the end
if (i === set.moves.length - 1) {
while (set.moves.length > 4 && !set.moves[set.moves.length - 1]) {
set.moves.pop();
}
}
// if we have more than 4 moves, move the last move into the newly-cleared slot
if (set.moves.length > 4 && i < set.moves.length - 1) {
set.moves[i] = set.moves.pop()!;
}
}
} else if (set.moves.includes(name)) {
set.moves.splice(set.moves.indexOf(name), 1);
} else {
for (let i = 0; i < set.moves.length + 1; i++) {
if (!set.moves[i]) {
set.moves[i] = name;
break;
}
}
}
if (set.moves.length === 4 && set.moves.every(Boolean)) {
this.changeFocus({
setIndex,
type: reverse ? 'item' : 'stats',
});
} else {
if (editor.search.query) {
this.resetScroll();
}
editor.updateSearchMoves(set);
}
break;
}
editor.save();
this.props.onChange?.();
this.forceUpdate();
}
};
loadSampleSet = (setName: string) => {
const { editor } = this.props;
const setIndex = editor.innerFocus!.setIndex;
const set = editor.sets[setIndex];
if (!set?.species) return;
const data = TeamEditorState.sampleSets?.[editor.format];
const sid = toID(set.species);
const setTemplate = data?.dex?.[set.species]?.[setName] ?? data?.dex?.[sid]?.[setName] ??
data?.stats?.[set.species]?.[setName] ?? data?.stats?.[sid]?.[setName];
if (!setTemplate) return;
const applied: Partial<Dex.PokemonSet> = JSON.parse(JSON.stringify(setTemplate));
Object.assign(set, applied);
editor.save();
this.props.onUpdate?.();
this.forceUpdate();
};
handleLoadUserSet = (ev: Event) => {
const setName = (ev.target as HTMLButtonElement).value;
this.loadUserSet(setName);
};
loadUserSet = (setName: string) => {
const { editor } = this.props;
const setIndex = editor.innerFocus!.setIndex;
const set = editor.sets[setIndex];
if (!set?.species) return;
const userSets = editor.getUserSets(set);
const setTemplate = userSets?.[setName];
if (!setTemplate) return;
const applied: Partial<Dex.PokemonSet> = JSON.parse(JSON.stringify(setTemplate));
delete applied.name;
Object.assign(set, applied);
editor.save();
this.props.onUpdate?.();
this.forceUpdate();
};
updateSearch = (ev: Event) => {
const searchBox = ev.currentTarget as HTMLInputElement;
this.props.editor.setSearchValue(searchBox.value);
this.resetScroll();
this.forceUpdate();
};
handleClickFilters = (ev: Event) => {
const search = this.props.editor.search;
let target = ev.target as HTMLElement | null;
while (target && target.className !== 'dexlist') {
if (target.tagName === 'BUTTON') {
const filter = target.getAttribute('data-filter');
if (filter) {
search.removeFilter(filter.split(':') as any);
const searchBox = this.base!.querySelector<HTMLInputElement>('input[name=value]');
search.find(searchBox?.value || '');
if (!TeamEditor.probablyMobile()) searchBox?.select();
this.forceUpdate();
ev.preventDefault();
ev.stopPropagation();
break;
}
}
target = target.parentElement;
}
};
keyDownSearch = (ev: KeyboardEvent) => {
const searchBox = ev.currentTarget as HTMLInputElement;
const { editor } = this.props;
switch (ev.keyCode) {
case 8: // backspace
if (searchBox.selectionStart === 0 && searchBox.selectionEnd === 0) {
editor.search.removeFilter();
editor.setSearchValue(searchBox.value);
this.resetScroll();
this.forceUpdate();
}
break;
case 38: // up
editor.upSearchValue();
const resultsUp = this.base!.querySelector('.wizardsearchresults');
if (resultsUp) {
resultsUp.scrollTop = Math.max(0, editor.searchIndex * 33 - Math.trunc((window.innerHeight - 300) / 2));
}
this.forceUpdate();
ev.preventDefault();
break;
case 40: // down
editor.downSearchValue();
const resultsDown = this.base!.querySelector('.wizardsearchresults');
if (resultsDown) {
resultsDown.scrollTop = Math.max(0, editor.searchIndex * 33 - Math.trunc((window.innerHeight - 300) / 2));
}
this.forceUpdate();
ev.preventDefault();
break;
case 37: // left
// prevent jumping to other rooms
ev.stopImmediatePropagation();
break;
case 39: // right
// prevent jumping to other rooms
ev.stopImmediatePropagation();
break;
case 13: // enter
case 9: // tab
const value = editor.selectSearchValue();
if (editor.innerFocus?.type !== 'move') searchBox.value = value || '';
if (value !== null) {
if (ev.keyCode === 9 && editor.innerFocus?.type === 'move') {
this.changeFocus({
setIndex: editor.innerFocus.setIndex,
type: ev.shiftKey ? 'item' : 'stats',
});
} else {
const [name, moveSlot] = value.split('|');
this.selectResult(editor.innerFocus?.type || '', name, moveSlot, ev.keyCode === 9 && ev.shiftKey);
}
} else {
this.clearSearchBox();
editor.setSearchValue('');
this.resetScroll();
this.forceUpdate();
}
ev.preventDefault();
break;
}
};
scrollResults = (ev: Event) => {
if (!(ev.currentTarget as HTMLElement).scrollTop) return;
this.windowing = false;
if (document.documentElement.clientWidth === document.documentElement.scrollWidth) {
(ev.currentTarget as any).scrollIntoViewIfNeeded?.();
}
this.forceUpdate();
};
resetScroll() {
this.windowing = true;
const searchResults = this.base!.querySelector('.wizardsearchresults');
if (searchResults) searchResults.scrollTop = 0;
}
windowResults() {
if (this.windowing) {
return Math.ceil(window.innerHeight / 33);
}
return null;
}
override componentDidUpdate() {
const searchBox = this.base!.querySelector<HTMLInputElement>('input[name=value], input[name=nickname]');
if (this.setSearchBox !== null) {
if (searchBox) {
searchBox.value = this.setSearchBox;
if (!TeamEditor.probablyMobile()) searchBox.select();
}
this.setSearchBox = null;
}
const filters = this.base!.querySelector('.dexlist-filters');
if (searchBox && searchBox.name === 'value') {
if (filters) {
const { width } = filters.getBoundingClientRect();
searchBox.style.paddingLeft = `${width + 5}px`;
} else {
searchBox.style.paddingLeft = `3px`;
}
}
}
renderInnerFocus() {
const { editor } = this.props;
if (!editor.innerFocus) return null;
const { type, setIndex } = editor.innerFocus;
const set = this.props.editor.sets[setIndex] as Dex.PokemonSet | undefined;
const cur = (i: number) => setIndex === i ? ' cur' : '';
const sampleSets = type === 'ability' ? editor.getSampleSets(set!) : [];
const userSets = type === 'ability' ? editor.getUserSets(set!) : null;
return <div class="team-focus-editor">
<ul class="tabbar">
<li class="home-li"><button class="button" onClick={this.setFocus}>
<i class="fa fa-chevron-left" aria-hidden></i> Back
</button></li>
{editor.sets.map((curSet, i) => <li><button
class={`button picontab${cur(i)}`} onClick={this.setFocus} value={`${type}|${i}`}
>
<PSIcon pokemon={curSet} /><br />
{editor.getNickname(curSet)}
</button></li>)}
{editor.canAdd() && <li><button
class={`button picontab${cur(editor.sets.length)}`} onClick={this.setFocus} value={`pokemon|${editor.sets.length}`}
>
<i class="fa fa-plus"></i>
</button></li>}
</ul>
<div class="pad" style="padding-top:0">{this.renderSet(set, setIndex)}</div>
{type === 'stats' ? (
<StatForm editor={editor} set={set!} onChange={this.handleSetChange} />
) : type === 'details' ? (
<DetailsForm editor={editor} set={set!} onChange={this.handleSetChange} />
) : (
<div>
<div class="searchboxwrapper pad" onClick={this.handleClickFilters}>
<input
type="search" name="value" class="textbox" placeholder="Search or filter"
onInput={this.updateSearch} onKeyDown={this.keyDownSearch} autocomplete="off"
/>
{PSSearchResults.renderFilters(editor.search)}
</div>
<div class="wizardsearchresults" onScroll={this.scrollResults}>
<PSSearchResults
search={editor.search} hideFilters resultIndex={editor.searchIndex}
onSelect={this.selectResult} windowing={this.windowResults()}
/>
{sampleSets?.length !== 0 && (
<div class="sample-sets">
<h3>Sample sets</h3>
{sampleSets ? (
<div>
{sampleSets.map(setName => <>
<button class="button" onClick={() => this.loadSampleSet(setName)}>
{setName}
</button> {}
</>)}
</div>
) : (
<div>Loading...</div>
)}
</div>
)}
{userSets !== null && (
<div class="sample-sets">
<h3>Box sets</h3>
{Object.keys(userSets).length > 0 ? (
<div>
{Object.keys(userSets).map(setName => <>
<button class="button" value={setName} onClick={this.handleLoadUserSet}>
{setName}
</button> {}
</>)}
</div>
) : (
<div>No {set!.species} sets found in boxes</div>
)}
</div>
)}
</div>
</div>
)}
</div>;
}
override render() {
const { editor } = this.props;
if (editor.innerFocus) return this.renderInnerFocus();
if (editor.fetching) {
return <div class="teameditor">Fetching Paste...</div>;
}
const clipboard = TeamEditorState.clipboard;
const willNotMove = (i: number) => (
clipboard?.teams && !clipboard.otherSets && clipboard.teams[editor.team.key] &&
Object.keys(clipboard.teams[editor.team.key]?.sets).length === 1 &&
!!(clipboard.teams[editor.team.key]?.sets[i] || clipboard.teams[editor.team.key]?.sets[i - 1])
);
const pasteControls = (i: number) => editor.readonly ? (
null
) : clipboard ? <p>
<button class="button notifying" onClick={this.pasteSet} value={i}>
<i class="fa fa-clipboard" aria-hidden></i> Paste copy here
</button> {}
{!willNotMove(i) && <button class="button notifying" onClick={this.moveSet} value={i} disabled={clipboard.readonly}>
<i class="fa fa-arrow-right" aria-hidden></i> Move here
</button>}
</p> : editor.deletedSet?.index === i ? <p style="text-align:right">
<button class="button" onClick={this.undeleteSet}>
<i class="fa fa-undo" aria-hidden></i> Undo delete
</button>
</p> : null;
return <div class="teameditor">
{editor.sets.map((set, i) => [
pasteControls(i),
this.renderSet(set, i),
])}
{pasteControls(editor.sets.length)}
{editor.canAdd() && <p><button class="button big" onClick={this.setFocus} value={`pokemon|${editor.sets.length}`}>
<i class="fa fa-plus" aria-hidden></i> Add Pok&eacute;mon
</button></p>}
</div>;
}
}
class StatForm extends preact.Component<{
editor: TeamEditorState,
set: Dex.PokemonSet,
onChange: () => void,
}> {
static renderStatGraph(set: Dex.PokemonSet, editor: TeamEditorState, evs?: boolean) {
const defaultEV = (editor.gen > 2 ? 0 : 252);
const ivs = editor.getIVs(set);
return Dex.statNames.map(statID => {
if (statID === 'spd' && editor.gen === 1) return null;
const stat = editor.getStat(statID, set, ivs[statID]);
let ev: number | string = set.evs ? (set.evs[statID] || 0) : defaultEV;
let width = stat * 75 / 504;
if (statID === 'hp') width = stat * 75 / 704;
if (width > 75) width = 75;
let hue = Math.floor(stat * 180 / 714);
if (hue > 360) hue = 360;
const statName = editor.gen === 1 && statID === 'spa' ? 'Spc' : BattleStatNames[statID];
if (evs && !ev && !set.evs && statID === 'hp') ev = 'EVs';
return <span class="statrow">
<label>{statName}</label> {}
<span class="statgraph">
<span style={`width:${width}px;background:hsl(${hue},40%,75%);border-color:hsl(${hue},40%,45%)`}></span>
</span> {}
{!evs && <em>{stat}</em>}
{evs && <em>{ev || ''}</em>}
{evs && (BattleNatures[set.nature!]?.plus === statID ? (
<small>+</small>
) : BattleNatures[set.nature!]?.minus === statID ? (
<small>&minus;</small>
) : null)}
</span>;
});
}
renderIVMenu() {
const { editor, set } = this.props;
if (editor.gen <= 2) return null;
const hpType = editor.getHPMove(set);
const hpIVdata = hpType && !editor.canHyperTrain(set) && editor.getHPIVs(hpType) || null;
const autoSpread = set.ivs && editor.defaultIVs(set, false);
const autoSpreadValue = autoSpread && Object.values(autoSpread).join('/');
if (!hpIVdata) {
return <select name="ivspread" class="button" onChange={this.changeIVSpread}>
<option value="" selected>IV spreads</option>
{autoSpreadValue && <option value="auto">Auto ({autoSpreadValue})</option>}
<optgroup label="min Atk">
<option value="31/0/31/31/31/31">31/0/31/31/31/31</option>
</optgroup>
<optgroup label="min Atk, min Spe">
<option value="31/0/31/31/31/0">31/0/31/31/31/0</option>
</optgroup>
<optgroup label="max all">
<option value="31/31/31/31/31/31">31/31/31/31/31/31</option>
</optgroup>
<optgroup label="min Spe">
<option value="31/31/31/31/31/0">31/31/31/31/31/0</option>
</optgroup>
</select>;
}
const minStat = editor.gen >= 6 ? 0 : 2;
const hpIVs = hpIVdata.map(ivs => ivs.split('').map(iv => parseInt(iv)));
return <select name="ivspread" class="button" onChange={this.changeIVSpread}>
<option value="" selected>Hidden Power {hpType} IVs</option>
{autoSpreadValue && <option value="auto">Auto ({autoSpreadValue})</option>}
<optgroup label="min Atk">
{hpIVs.map(ivs => {
const spread = ivs.map((iv, i) => (i === 1 ? minStat : 30) + iv).join('/');
return <option value={spread}>{spread}</option>;
})}
</optgroup>
<optgroup label="min Atk, min Spe">
{hpIVs.map(ivs => {
const spread = ivs.map((iv, i) => (i === 5 || i === 1 ? minStat : 30) + iv).join('/');
return <option value={spread}>{spread}</option>;
})}
</optgroup>
<optgroup label="max all">
{hpIVs.map(ivs => {
const spread = ivs.map(iv => 30 + iv).join('/');
return <option value={spread}>{spread}</option>;
})}
</optgroup>
<optgroup label="min Spe">
{hpIVs.map(ivs => {
const spread = ivs.map((iv, i) => (i === 5 ? minStat : 30) + iv).join('/');
return <option value={spread}>{spread}</option>;
})}
</optgroup>
</select>;
}
smogdexLink(s: string) {
const { editor } = this.props;
const species = editor.dex.species.get(s);
let format: string = editor.format;
let smogdexid: string = toID(species.baseSpecies);
if (species.id === 'meowstic') {
smogdexid = 'meowstic-m';
} else if (species.forme) {
switch (species.baseSpecies) {
case 'Alcremie':
case 'Basculin':
case 'Burmy':
case 'Castform':
case 'Cherrim':
case 'Deerling':
case 'Flabebe':
case 'Floette':
case 'Florges':
case 'Furfrou':
case 'Gastrodon':
case 'Genesect':
case 'Keldeo':
case 'Mimikyu':
case 'Minior':
case 'Pikachu':
case 'Polteageist':
case 'Sawsbuck':
case 'Shellos':
case 'Sinistea':
case 'Tatsugiri':
case 'Vivillon':
break;
default:
smogdexid += '-' + toID(species.forme);
break;
}
}
let generationNumber = 9;
if (format.startsWith('gen')) {
let number = parseInt(format.charAt(3), 10);
if (1 <= number && number <= 8) {
generationNumber = number;
}
format = format.slice(4);
}
const generation = ['rb', 'gs', 'rs', 'dp', 'bw', 'xy', 'sm', 'ss', 'sv'][generationNumber - 1];
if (format === 'battlespotdoubles') {
smogdexid += '/vgc15';
} else if (format === 'doublesou' || format === 'doublesuu') {
smogdexid += '/doubles';
} else if (
format === 'ou' || format === 'uu' || format === 'ru' || format === 'nu' || format === 'pu' ||
format === 'lc' || format === 'monotype' || format === 'mixandmega' || format === 'nfe' ||
format === 'nationaldex' || format === 'stabmons' || format === '1v1' || format === 'almostanyability'
) {
smogdexid += '/' + format;
} else if (format === 'balancedhackmons') {
smogdexid += '/bh';
} else if (format === 'anythinggoes') {
smogdexid += '/ag';
} else if (format === 'nationaldexag') {
smogdexid += '/national-dex-ag';
}
return `http://smogon.com/dex/${generation}/pokemon/${smogdexid}/`;
}
handleGuess = () => {
const { editor, set } = this.props;
const team = editor.team;
const guess = new BattleStatGuesser(team.format).guess(set);
set.evs = guess.evs;
this.plus = guess.plusStat || null;
this.minus = guess.minusStat || null;
this.updateNatureFromPlusMinus();
this.props.onChange();
};
handleOptimize = () => {
const { editor, set } = this.props;
const team = editor.team;
const optimized = BattleStatOptimizer(set, team.format);
if (!optimized) return;
set.evs = optimized.evs;
this.plus = optimized.plus || null;
this.minus = optimized.minus || null;
this.updateNatureFromPlusMinus();
this.props.onChange();
};
renderSpreadGuesser() {
const { editor, set } = this.props;
const team = editor.team;
if (editor.gen < 3) {
return <p>
(<a target="_blank" href={this.smogdexLink(set.species)}>Smogon&nbsp;analysis</a>)
</p>;
}
const guess = new BattleStatGuesser(team.format).guess(set);
const role = guess.role;
const guessedEVs = guess.evs;
const guessedPlus = guess.plusStat || null;
const guessedMinus = guess.minusStat || null;
return <p class="suggested">
<small>Guessed spread: </small>
{role === '?' ? (
"(Please choose 4 moves to get a guessed spread)"
) : (
<button name="setStatFormGuesses" class="button" onClick={this.handleGuess}>{role}: {}
{
Dex.statNames.map(statID => guessedEVs[statID] ? `${guessedEVs[statID]} ${BattleStatNames[statID]}` : null)
.filter(Boolean).join(' / ')
}
{!!(guessedPlus && guessedMinus) && (
` (+${BattleStatNames[guessedPlus]}, -${BattleStatNames[guessedMinus]})`
)}
</button>
)}
<small> (<a target="_blank" href={this.smogdexLink(set.species)}>Smogon&nbsp;analysis</a>)</small>
{/* <small>
({role} | bulk: phys {Math.round(guess.moveCount.physicalBulk / 1000)}
{} + spec {Math.round(guess.moveCount.specialBulk / 1000)}
{} = {Math.round(guess.moveCount.bulk / 1000)})
</small> */}
</p>;
}
renderStatOptimizer() {
const optimized = BattleStatOptimizer(this.props.set, this.props.editor.format);
if (!optimized) return null;
return <p>
<small><em>Protip:</em> Use a different nature to {
optimized.savedEVs ?
`save ${optimized.savedEVs} EVs` :
'get higher stats'
}: </small>
<button name="setStatFormOptimization" class="button" onClick={this.handleOptimize}>
{
Dex.statNames.map(statID => optimized.evs[statID] ? `${optimized.evs[statID]} ${BattleStatNames[statID]}` : null)
.filter(Boolean).join(' / ')
}
{!!(optimized.plus && optimized.minus) && (
` (+${BattleStatNames[optimized.plus]}, -${BattleStatNames[optimized.minus]})`
)}
</button>
</p>;
}
setInput(name: string, value: string) {
const evInput = this.base!.querySelector<HTMLInputElement>(`input[name="${name}"]`);
if (evInput) evInput.value = value;
}
update(init?: boolean) {
const { set } = this.props;
const nature = BattleNatures[set.nature!];
const skipID = !init ? this.base!.querySelector<HTMLInputElement>('input:focus')?.name : undefined;
if (nature?.plus) {
this.plus = nature?.plus || null;
this.minus = nature?.minus || null;
} else if (this.plus && this.minus) {
// if only one of plus or minus is set, clearing Nature doesn't change them
this.plus = null;
this.minus = null;
}
for (const statID of Dex.statNames) {
const ev = `${set.evs?.[statID] || ''}`;
const plusMinus = this.plus === statID ? '+' : this.minus === statID ? '-' : '';
const iv = this.ivToDv(set.ivs?.[statID]);
if (skipID !== `ev-${statID}`) this.setInput(`ev-${statID}`, ev + plusMinus);
if (skipID !== `iv-${statID}`) this.setInput(`iv-${statID}`, iv);
}
}
override componentDidMount(): void {
this.update(true);
}
override componentDidUpdate(): void {
this.update();
}
plus: Dex.StatNameExceptHP | null = null;
minus: Dex.StatNameExceptHP | null = null;
renderStatbar(stat: number, statID: StatName) {
let width = stat * 180 / 504;
if (statID === 'hp') width = Math.floor(stat * 180 / 704);
if (width > 179) width = 179;
let hue = Math.floor(stat * 180 / 714);
if (hue > 360) hue = 360;
return <span
style={`width:${Math.floor(width)}px;background:hsl(${hue},85%,45%);border-color:hsl(${hue},85%,35%)`}
></span>;
}
changeEV = (ev: Event) => {
const target = ev.currentTarget as HTMLInputElement;
const { set } = this.props;
const statID = target.name.split('-')[1] as Dex.StatName;
let value = Math.abs(parseInt(target.value));
if (isNaN(value)) {
if (set.evs) delete set.evs[statID];
} else {
if (this.maxEVs() < 6 * 252 || this.props.editor.isLetsGo) {
set.evs ||= {};
} else {
set.evs ||= { hp: 252, atk: 252, def: 252, spa: 252, spd: 252, spe: 252 };
}
set.evs[statID] = value;
}
if (target.type === 'range') {
// enforce limit
const maxEv = this.maxEVs();
if (maxEv < 6 * 252) {
let totalEv = 0;
for (const curEv of Object.values(set.evs || {})) totalEv += curEv;
if (totalEv > maxEv && totalEv - value <= maxEv) {
set.evs![statID] = maxEv - (totalEv - value) - (maxEv % 4);
}
}
} else {
if (target.value.includes('+')) {
if (statID === 'hp') {
alert("Natures cannot raise or lower HP.");
return;
}
this.plus = statID;
} else if (this.plus === statID) {
this.plus = null;
}
if (target.value.includes('-')) {
if (statID === 'hp') {
alert("Natures cannot raise or lower HP.");
return;
}
this.minus = statID;
} else if (this.minus === statID) {
this.minus = null;
}
this.updateNatureFromPlusMinus();
}
this.props.onChange();
};
updateNatureFromPlusMinus = () => {
const { set } = this.props;
set.nature = Teams.getNatureFromPlusMinus(this.plus, this.minus) || undefined;
};
/** Converts DV/IV in a textbox to the value in set. */
dvToIv(dvOrIvString?: string): number | null {
const dvOrIv = Number(dvOrIvString);
if (isNaN(dvOrIv)) return null;
const useIVs = this.props.editor.gen > 2;
return useIVs ? dvOrIv : (dvOrIv === 15 ? 31 : dvOrIv * 2);
}
/** Converts set.iv value to a DV/IV for a text box. */
ivToDv(iv?: number | null): string {
if (iv === null || iv === undefined) return '';
const useIVs = this.props.editor.gen > 2;
return `${useIVs ? iv : Math.trunc(iv / 2)}`;
}
changeIV = (ev: Event) => {
const target = ev.currentTarget as HTMLInputElement;
const { set } = this.props;
const statID = target.name.split('-')[1] as StatName;
const value = this.dvToIv(target.value);
if (value === null) {
if (set.ivs) {
delete set.ivs[statID];
if (Object.values(set.ivs).every(iv => iv === undefined)) {
set.ivs = undefined;
}
}
} else {
set.ivs ||= { hp: 31, atk: 31, def: 31, spa: 31, spd: 31, spe: 31 };
set.ivs[statID] = value;
}
this.props.onChange();
};
changeNature = (ev: Event) => {
const target = ev.currentTarget as HTMLSelectElement;
const { set } = this.props;
const nature = target.value as Dex.NatureName;
if (nature === 'Serious') {
delete set.nature;
} else {
set.nature = nature;
}
this.props.onChange();
};
changeIVSpread = (ev: Event) => {
const target = ev.currentTarget as HTMLSelectElement;
const { set } = this.props;
if (!target.value) return;
if (target.value === 'auto') {
set.ivs = undefined;
} else {
const [hp, atk, def, spa, spd, spe] = target.value.split('/').map(Number);
set.ivs = { hp, atk, def, spa, spd, spe };
}
this.props.onChange();
};
maxEVs() {
const editor = this.props.editor;
const useEVs = !editor.isLetsGo && editor.gen >= 3;
return useEVs ? 510 : Infinity;
}
override render() {
const { editor, set } = this.props;
const species = editor.dex.species.get(set.species);
const baseStats = species.baseStats;
const nature = BattleNatures[set.nature || 'Serious'];
const useEVs = !editor.isLetsGo;
// const useAVs = !useEVs && team.format.endsWith('norestrictions');
const maxEV = useEVs ? 252 : 200;
const stepEV = useEVs ? 4 : 1;
const defaultEV = useEVs && editor.gen <= 2 && !set.evs ? maxEV : 0;
const useIVs = editor.gen > 2;
// label column
const statNames = {
hp: 'HP',
atk: 'Attack',
def: 'Defense',
spa: 'Sp. Atk.',
spd: 'Sp. Def.',
spe: 'Speed',
};
if (editor.gen === 1) statNames.spa = 'Special';
const ivs = editor.getIVs(set);
const stats = Dex.statNames.filter(statID => editor.gen > 1 || statID !== 'spd').map(statID => [
statID, statNames[statID], editor.getStat(statID, set, ivs[statID]),
] as const);
let remaining = null;
const maxEVs = this.maxEVs();
if (maxEVs < 6 * 252) {
let totalEv = 0;
for (const ev of Object.values(set.evs || {})) totalEv += ev;
if (totalEv <= maxEVs) {
remaining = (totalEv > (maxEVs - 2) ? 0 : (maxEVs - 2) - totalEv);
} else {
remaining = maxEVs - totalEv;
}
remaining ||= null;
}
const defaultIVs = editor.defaultIVs(set);
return <div style="font-size:10pt" role="dialog" aria-label="Stats">
<div class="resultheader"><h3>EVs, IVs, and Nature</h3></div>
<div class="pad">
{this.renderSpreadGuesser()}
<table>
<tr>
<th>{/* Stat name */}</th>
<th>Base</th>
<th class="setstatbar">{/* Stat bar */}</th>
<th>{useEVs ? 'EVs' : 'AVs'}</th>
<th>{/* EV slider */}</th>
<th>{useIVs ? 'IVs' : 'DVs'}</th>
<th>{/* Final stat */}</th>
</tr>
{stats.map(([statID, statName, stat]) => <tr>
<th style="text-align:right;font-weight:normal">{statName}</th>
<td style="text-align:right"><strong>{baseStats[statID]}</strong></td>
<td class="setstatbar">{this.renderStatbar(stat, statID)}</td>
<td><input
name={`ev-${statID}`} placeholder={`${defaultEV || ''}`}
type="text" inputMode="numeric" class="textbox default-placeholder" style="width:40px"
onInput={this.changeEV} onChange={this.changeEV}
/></td>
<td><input
name={`evslider-${statID}`} value={set.evs?.[statID] ?? defaultEV} min="0" max={maxEV} step={stepEV}
type="range" class="evslider" tabIndex={-1} aria-hidden
onInput={this.changeEV} onChange={this.changeEV}
/></td>
<td><input
name={`iv-${statID}`} min={0} max={useIVs ? 31 : 15} placeholder={`${defaultIVs[statID]}`} style="width:40px"
type="number" inputMode="numeric" class="textbox default-placeholder" onInput={this.changeIV} onChange={this.changeIV}
/></td>
<td style="text-align:right"><strong>{stat}</strong></td>
</tr>)}
<tr>
<td colSpan={2}></td>
<td class="setstatbar" style="text-align:right">{remaining !== null ? 'Remaining:' : ''}</td>
<td style="text-align:center">{remaining && remaining < 0 ? <b class="message-error">{remaining}</b> : remaining}</td>
<td colSpan={3} style="text-align:right">{this.renderIVMenu()}</td>
</tr>
</table>
{editor.gen >= 3 && <p>
Nature: <select name="nature" class="button" onChange={this.changeNature}>
{Object.entries(BattleNatures).map(([natureName, curNature]) => (
<option value={natureName} selected={curNature === nature}>
{natureName}
{curNature.plus && ` (+${BattleStatNames[curNature.plus]}, -${BattleStatNames[curNature.minus!]})`}
</option>
))}
</select>
</p>}
{editor.gen >= 3 && <p>
<small><em>Protip:</em> You can also set natures by typing <kbd>+</kbd> and <kbd>-</kbd> in the EV box.</small>
</p>}
{editor.gen >= 3 && this.renderStatOptimizer()}
</div>
</div>;
}
}
class DetailsForm extends preact.Component<{
editor: TeamEditorState,
set: Dex.PokemonSet,
onChange: () => void,
}> {
update(init?: boolean) {
const { set } = this.props;
const skipID = !init ? this.base!.querySelector<HTMLInputElement>('input:focus')?.name : undefined;
const nickname = this.base!.querySelector<HTMLInputElement>('input[name="nickname"]');
if (nickname && skipID !== 'nickname') nickname.value = set.name || '';
}
override componentDidMount(): void {
this.update(true);
}
override componentDidUpdate(): void {
this.update();
}
changeNickname = (ev: Event) => {
const target = ev.currentTarget as HTMLInputElement;
const { set } = this.props;
if (target.value) {
set.name = target.value.trim();
} else {
delete set.name;
}
this.props.onChange();
};
changeTera = (ev: Event) => {
const target = ev.currentTarget as HTMLInputElement;
const { editor, set } = this.props;
const species = editor.dex.species.get(set.species);
if (!target.value || target.value === (species.requiredTeraType || species.types[0])) {
delete set.teraType;
} else {
set.teraType = target.value.trim();
}
this.props.onChange();
};
changeLevel = (ev: Event) => {
const target = ev.currentTarget as HTMLInputElement;
const { set } = this.props;
if (target.value) {
set.level = parseInt(target.value.trim());
} else {
delete set.level;
}
this.props.onChange();
};
changeGender = (ev: Event) => {
const target = ev.currentTarget as HTMLInputElement;
const { set } = this.props;
if (target.value) {
set.gender = target.value.trim();
} else {
delete set.gender;
}
this.props.onChange();
};
changeHappiness = (ev: Event) => {
const target = ev.currentTarget as HTMLInputElement;
const { set } = this.props;
if (target.value) {
set.happiness = parseInt(target.value.trim());
} else {
delete set.happiness;
}
this.props.onChange();
};
changeShiny = (ev: Event) => {
const target = ev.currentTarget as HTMLInputElement;
const { set } = this.props;
if (target.value) {
set.shiny = true;
} else {
delete set.shiny;
}
this.props.onChange();
};
changeDynamaxLevel = (ev: Event) => {
const target = ev.currentTarget as HTMLInputElement;
const { set } = this.props;
if (target.value) {
set.dynamaxLevel = parseInt(target.value.trim());
} else {
delete set.dynamaxLevel;
}
this.props.onChange();
};
changeGigantamax = (ev: Event) => {
const target = ev.currentTarget as HTMLInputElement;
const { set } = this.props;
if (target.checked) {
set.gigantamax = true;
} else {
delete set.gigantamax;
}
this.props.onChange();
};
changeHPType = (ev: Event) => {
const target = ev.currentTarget as HTMLInputElement;
const { set } = this.props;
if (target.value) {
set.hpType = target.value;
} else {
delete set.hpType;
}
this.props.onChange();
};
renderGender(gender: Dex.GenderName) {
const genderTable = { 'M': "Male", 'F': "Female" };
if (gender === 'N') return 'Unknown';
return <>
<img src={`${Dex.fxPrefix}gender-${gender.toLowerCase()}.png`} alt="" width="7" height="10" class="pixelated" /> {}
{genderTable[gender]}
</>;
}
render() {
const { editor, set } = this.props;
const species = editor.dex.species.get(set.species);
return <div style="font-size:10pt" role="dialog" aria-label="Details">
<div class="resultheader"><h3>Details</h3></div>
<div class="pad">
<p><label class="label">Nickname: <input
name="nickname" class="textbox default-placeholder" placeholder={species.baseSpecies}
onInput={this.changeNickname} onChange={this.changeNickname}
/></label></p>
<p><label class="label">Level: <input
name="level" value={set.level ?? ''} placeholder={`${editor.defaultLevel}`}
type="number" inputMode="numeric" min="1" max="100" step="1"
class="textbox inputform numform default-placeholder" style="width: 50px"
onInput={this.changeLevel} onChange={this.changeLevel}
/></label><small>(You probably want to change the team's levels by changing the format, not here)</small></p>
{editor.gen > 1 && (<>
<p><div class="label">Shiny: <div class="labeled">
<label class="checkbox inline"><input
type="radio" name="shiny" value="true" checked={set.shiny}
onInput={this.changeShiny} onChange={this.changeShiny}
/> <img src={`${Dex.resourcePrefix}sprites/misc/shiny.png`} width={22} height={22} alt="Shiny" /> Yes</label>
<label class="checkbox inline"><input
type="radio" name="shiny" value="" checked={!set.shiny}
onInput={this.changeShiny} onChange={this.changeShiny}
/> No</label>
</div></div></p>
<p><div class="label">Gender: {species.gender ? (
<strong>{this.renderGender(species.gender)}</strong>
) : (
<div class="labeled">
<label class="checkbox inline"><input
type="radio" name="gender" value="M" checked={set.gender === 'M'}
onInput={this.changeGender} onChange={this.changeGender}
/> {this.renderGender('M')}</label>
<label class="checkbox inline"><input
type="radio" name="gender" value="F" checked={set.gender === 'F'}
onInput={this.changeGender} onChange={this.changeGender}
/> {this.renderGender('F')}</label>
<label class="checkbox inline"><input
type="radio" name="gender" value="" checked={!set.gender || set.gender === 'N'}
onInput={this.changeGender} onChange={this.changeGender}
/> Random</label>
</div>
)}</div></p>
{editor.isLetsGo ? (
<p><label class="label">Happiness: <input
name="happiness" value="" placeholder="70"
type="number" inputMode="numeric"
class="textbox inputform numform default-placeholder" style="width: 50px"
onInput={this.changeHappiness} onChange={this.changeHappiness}
/></label></p>
) : (editor.gen < 8 || editor.isNatDex) && (
<p><label class="label">Happiness: <input
name="happiness" value={set.happiness ?? ''} placeholder="255"
type="number" inputMode="numeric" min="0" max="255" step="1"
class="textbox inputform numform default-placeholder" style="width: 50px"
onInput={this.changeHappiness} onChange={this.changeHappiness}
/></label></p>
)}
</>
)}
{editor.gen === 8 && !editor.isBDSP && !species.cannotDynamax && (
<p>
<label class="label" style="display:inline">Dynamax Level: <input
name="dynamaxlevel" value={set.dynamaxLevel ?? ''} placeholder="10"
type="number" inputMode="numeric" min="0" max="10" step="1" class="textbox inputform numform default-placeholder"
onInput={this.changeDynamaxLevel} onChange={this.changeDynamaxLevel}
/></label> {}
{species.canGigantamax ? (
<label class="checkbox inline"><input
type="checkbox" name="gigantamax" value="true" checked={set.gigantamax}
onInput={this.changeGigantamax} onChange={this.changeGigantamax}
/> Gigantamax</label>
) : species.forme === 'Gmax' && (
<label class="checkbox inline"><input
type="checkbox" checked disabled
/> Gigantamax</label>
)}
</p>
)}
{((!editor.isLetsGo && editor.gen === 7) || editor.isNatDex || species.baseSpecies === 'Unown') && <p>
<label class="label">Hidden Power Type: <select name="hptype" class="button" onChange={this.changeHPType}>
{Dex.types.all().map(type => (
type.HPivs && <option value={type.name} selected={editor.getHPType(set) === type.name}>
{type.name}
</option>
))}
</select></label>
</p>}
{editor.gen === 9 && <p>
<label class="label" title="Tera Type">
Tera Type: {}
{species.requiredTeraType && editor.formeLegality === 'normal' ? (
<select name="teratype" class="button cur" disabled><option>{species.requiredTeraType}</option></select>
) : (
<select name="teratype" class="button" onChange={this.changeTera}>
{Dex.types.all().map(type => (
<option value={type.name} selected={(set.teraType || species.requiredTeraType || species.types[0]) === type.name}>
{type.name}
</option>
))}
</select>
)}
</label>
</p>}
{species.cosmeticFormes && <div>
<p><strong>Form:</strong></p>
<div style="display:flex;flex-wrap:wrap;gap:6px;max-width:400px;">
{(() => {
const baseId = toID(species.baseSpecies);
const forms = species.cosmeticFormes?.length ? [baseId, ...species.cosmeticFormes.map(toID)] : [baseId];
return forms.map(id => {
const sp = editor.dex.species.get(id);
const isCur = toID(set.species) === id;
return <button
value={id} class={`button piconbtn${isCur ? ' cur' : ''}`}
style={{ padding: '2px' }} onClick={this.selectSprite}
>
<PSIcon pokemon={{ species: sp.name } as Dex.PokemonSet} />
<br />{sp.forme || sp.baseForme || sp.baseSpecies}
</button>;
});
})()}
</div>
</div>}
</div>
</div>;
}
selectSprite = (ev: Event) => {
const target = ev.currentTarget as HTMLButtonElement;
const formId = target.value;
const { editor, set } = this.props;
const species = editor.dex.species.get(formId);
if (!species.exists) return;
editor.changeSpecies(set, species.name);
this.props.onChange();
this.forceUpdate();
};
}