diff --git a/play.pokemonshowdown.com/src/battle-dex-data.ts b/play.pokemonshowdown.com/src/battle-dex-data.ts index 2a6af9b48..be2f089ca 100644 --- a/play.pokemonshowdown.com/src/battle-dex-data.ts +++ b/play.pokemonshowdown.com/src/battle-dex-data.ts @@ -1232,6 +1232,7 @@ export class Move implements Effect { readonly pressureTarget: MoveTarget; readonly flags: Readonly; readonly critRatio: number; + readonly damage?: number | 'level' | false | null; readonly desc: string; readonly shortDesc: string; @@ -1273,6 +1274,7 @@ export class Move implements Effect { this.pressureTarget = data.pressureTarget || this.target; this.flags = data.flags || {}; this.critRatio = data.critRatio === 0 ? 0 : (data.critRatio || 1); + this.damage = data.damage; // TODO: move to text.js this.desc = data.desc; diff --git a/play.pokemonshowdown.com/src/battle-team-editor.tsx b/play.pokemonshowdown.com/src/battle-team-editor.tsx index c954d4f81..69b723ae7 100644 --- a/play.pokemonshowdown.com/src/battle-team-editor.tsx +++ b/play.pokemonshowdown.com/src/battle-team-editor.tsx @@ -41,11 +41,10 @@ class TeamEditorState extends PSModel { formeLegality: 'normal' | 'hackmons' | 'custom' = 'normal'; abilityLegality: 'normal' | 'hackmons' = 'normal'; defaultLevel = 100; - readonly: boolean; - constructor(team: Team, readonly = false) { + readonly = false; + constructor(team: Team) { super(); this.team = team; - this.readonly = readonly; this.sets = PSTeambuilder.unpackTeam(team.packedTeam); this.setFormat(team.format); window.search = this.search; @@ -213,7 +212,7 @@ class TeamEditorState extends PSModel { this.searchIndex--; } } - getResultValue(result: SearchRow) { + getResultValue(result: SearchRow): string { switch (result[0]) { case 'pokemon': return this.dex.species.get(result[1]).name; @@ -231,17 +230,19 @@ class TeamEditorState extends PSModel { return result[1]; } } - canAdd() { + 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; - if (!set.ivs) return this.getHPMove(set) || 'Dark'; + 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); @@ -254,19 +255,20 @@ class TeamEditorState extends PSModel { // } 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 (set.ivs[s] === undefined) set.ivs[s] = 31; - hpTypeX += i * (set.ivs[s] % 2); + 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) { + hpTypeMatters(set: Dex.PokemonSet): boolean { if (this.gen < 2) return false; if (this.gen > 7) return false; for (const move of set.moves) { @@ -288,6 +290,101 @@ class TeamEditorState extends PSModel { } 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 { + 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'].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 || ''; } @@ -336,7 +433,7 @@ class TeamEditorState extends PSModel { return null; } } - getStat(stat: StatName, set: Dex.PokemonSet, evOverride?: number, natureOverride?: number) { + getStat(stat: StatName, set: Dex.PokemonSet, ivOverride: number, evOverride?: number, natureOverride?: number) { const team = this.team; const supportsEVs = !team.format.includes('letsgo'); @@ -350,8 +447,7 @@ class TeamEditorState extends PSModel { const level = set.level || this.defaultLevel; const baseStat = species.baseStats[stat]; - let iv = set.ivs?.[stat] ?? 31; - if (this.gen <= 2) iv &= 30; + const iv = ivOverride; const ev = evOverride ?? set.evs?.[stat] ?? (this.gen > 2 ? 0 : 252); if (stat === 'hp') { @@ -553,7 +649,8 @@ export class TeamEditor extends preact.Component<{ ; } override render() { - this.editor ||= new TeamEditorState(this.props.team, this.props.readonly); + this.editor ||= new TeamEditorState(this.props.team); + this.editor.readonly = !!this.props.readonly; this.editor.narrow = this.props.narrow ?? document.body.offsetWidth < 500; if (this.props.team.format !== this.editor.format) { this.editor.setFormat(this.props.team.format); @@ -619,19 +716,27 @@ class TeamTextbox extends preact.Component<{ editor: TeamEditorState, onChange?: } input = () => this.updateText(); keyUp = () => this.updateText(true); - click = (ev: MouseEvent | KeyboardEvent) => { - if (ev.altKey || ev.ctrlKey || ev.metaKey) return; + contextMenu = (ev: MouseEvent) => { + if (!ev.shiftKey) { + if (this.closeMenu() || this.openInnerFocus()) { + 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; + 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) { @@ -679,6 +784,7 @@ class TeamTextbox extends preact.Component<{ editor: TeamEditorState, onChange?: case 9: // tab case 13: // enter if (ev.keyCode === 13 && ev.shiftKey) return; + if (ev.altKey || ev.metaKey) return; if (!this.innerFocus) { if ( this.textbox.selectionStart === this.textbox.value.length && @@ -686,7 +792,7 @@ class TeamTextbox extends preact.Component<{ editor: TeamEditorState, onChange?: ) { this.addPokemon(); } else { - this.click(ev); + this.openInnerFocus(); } ev.stopImmediatePropagation(); ev.preventDefault(); @@ -1040,8 +1146,14 @@ class TeamTextbox extends preact.Component<{ editor: TeamEditorState, onChange?: } } getSetRange(index: number) { - const start = this.setInfo[index]?.index ?? this.textbox.value.length; - const end = this.setInfo[index + 1]?.index ?? this.textbox.value.length; + 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) => { @@ -1239,7 +1351,7 @@ class TeamTextbox extends preact.Component<{ editor: TeamEditorState, onChange?: