diff --git a/.eslintrc.js b/.eslintrc.js index 9251d4fb1..f3aa66b17 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -23,7 +23,7 @@ module.exports = { "BattleFormats": false, "BattleFormatsData": false, "BattleLearnsets": false, "BattleItems": false, "BattleMoveAnims": false, "BattleMovedex": false, "BattleNatures": false, "BattleOtherAnims": false, "BattlePokedex": false,"BattlePokemonSprites": false, "BattlePokemonSpritesBW": false, "BattleSearchCountIndex": false, "BattleSearchIndex": false, "BattleArticleTitles": false, "BattleSearchIndexOffset": false, "BattleSearchIndexType": false, "BattleStatIDs": false, "BattleStatNames": false, "BattleStatusAnims": false, "BattleStatuses": false, "BattleTeambuilderTable": false, - "ModifiableValue": false, "BattleStatGuesser": false, "BattleText": true, "BattleTextAFD": false, "BattleTextNotAFD": false, + "ModifiableValue": false, "BattleStatGuesser": false, "BattleStatOptimizer": false, "BattleText": true, "BattleTextAFD": false, "BattleTextNotAFD": false, "BattleTextParser": false, // Generic global variables diff --git a/play.pokemonshowdown.com/js/client-teambuilder.js b/play.pokemonshowdown.com/js/client-teambuilder.js index 474e27497..3973bd74c 100644 --- a/play.pokemonshowdown.com/js/client-teambuilder.js +++ b/play.pokemonshowdown.com/js/client-teambuilder.js @@ -2040,6 +2040,7 @@ } } this.$chart.find('select[name=nature]').val(set.nature || 'Serious'); + this.checkStatOptimizations(); }, curChartType: '', curChartName: '', @@ -2465,15 +2466,58 @@ } buf += '

'; - buf += '

Protip: You can also set natures by typing + and - next to a stat.

'; + buf += '

Protip: You can also set natures by typing + and - next to a stat.

'; + + buf += '

'; } buf += ''; this.$chart.html(buf); + this.checkStatOptimizations(); }, setStatFormGuesses: function () { this.updateStatForm(true); }, + checkStatOptimizations: function () { + var optimized = BattleStatOptimizer(this.curSet, this.curTeam.format); + + if (optimized) { + var buf = ''; + var msg = ''; + if (optimized.savedEVs) { + msg = 'save ' + optimized.savedEVs + ' EVs'; + } else { + msg = 'get higher stats'; + } + buf += 'Protip: Use a different nature to ' + msg + ': '; + buf += ' '; + this.$chart.find('#statoptimizer').html(buf).show(); + } else { + this.$chart.find('#statoptimizer').hide(); + } + }, + setStatFormOptimization: function () { + var optimized = BattleStatOptimizer(this.curSet, this.curTeam.format); + this.curSet.evs = optimized.evs; + this.plus = optimized.plus; + this.minus = optimized.minus; + this.updateNature(); + this.save(); + this.updateStatGraph(); + this.natureChange(); + this.$chart.find('#statoptimizer').hide(); + }, setSlider: function (stat, val) { this.$chart.find('input[name=evslider-' + stat + ']').val(val || 0); }, diff --git a/play.pokemonshowdown.com/src/battle-dex-data.ts b/play.pokemonshowdown.com/src/battle-dex-data.ts index 9e1eceec0..23a17a940 100644 --- a/play.pokemonshowdown.com/src/battle-dex-data.ts +++ b/play.pokemonshowdown.com/src/battle-dex-data.ts @@ -19,7 +19,12 @@ */ type ID = string & {__isID: true}; -const BattleNatures: {[k in NatureName]: {plus?: StatName, minus?: StatName}} = { +interface Nature { + plus?: StatNameExceptHP; + minus?: StatNameExceptHP; +} + +const BattleNatures: {[k in NatureName]: Nature} = { Adamant: { plus: 'atk', minus: 'spa', diff --git a/play.pokemonshowdown.com/src/battle-tooltips.ts b/play.pokemonshowdown.com/src/battle-tooltips.ts index 0ba6bca77..7b8f1a465 100644 --- a/play.pokemonshowdown.com/src/battle-tooltips.ts +++ b/play.pokemonshowdown.com/src/battle-tooltips.ts @@ -2281,7 +2281,7 @@ interface PokemonSet { /** Defaults to random legal gender, NOT subject to gender ratios */ gender?: string; /** Defaults to flat 252's (200's/0's in Let's Go) (error in gen 3+) */ - evs?: StatsTable; + evs?: Partial; /** Defaults to whatever makes sense - flat 31's unless you have Gyro Ball etc */ ivs?: StatsTable; /** Defaults as you'd expect (100 normally, 50 in VGC-likes, 5 in LC) */ @@ -2904,7 +2904,155 @@ class BattleStatGuesser { } } +function BattleStatOptimizer(set: PokemonSet, formatid: ID) { + if (!set.evs) return null; + + const dex = Dex.mod(formatid.slice(0, 4) as ID); + const ignoreEVLimits = ( + dex.gen < 3 || + ((formatid.endsWith('hackmons') || formatid.endsWith('bh')) && dex.gen !== 6) || + formatid.includes('metronomebattle') || formatid.endsWith('norestrictions') + ); + const supportsEVs = !formatid.includes('letsgo'); + if (!supportsEVs || ignoreEVLimits) return false; + + const species = dex.species.get(set.species); + const level = set.level || 100; + const getStat = (stat: StatNameExceptHP, ev: number, nature: Nature) => { + const baseStat = species.baseStats[stat]; + const iv = set.ivs?.[stat] || 31; + let val = ~~(~~(2 * baseStat + iv + ~~(ev / 4)) * level / 100 + 5); + if (nature.plus === stat) { + val *= 1.1; + } else if (nature.minus === stat) { + val *= 0.9; + } + return ~~(val); + }; + + const origNature = BattleNatures[set.nature || 'Serious']; + const origStats = { + // no need to calculate hp + atk: getStat('atk', set.evs.atk || 0, origNature), + def: getStat('def', set.evs.def || 0, origNature), + spa: getStat('spa', set.evs.spa || 0, origNature), + spd: getStat('spd', set.evs.spd || 0, origNature), + spe: getStat('spe', set.evs.spe || 0, origNature), + }; + const getMinEVs = (stat: StatNameExceptHP, nature: Nature) => { + let ev = 0; + while (getStat(stat, ev, nature) < origStats[stat]) { + ev += 4; + } + return ev; + }; + + const origSpread = {evs: set.evs, ...origNature}; + let origLeftoverEVs = 508; + for (const stat of Dex.statNames) { + origLeftoverEVs -= origSpread.evs?.[stat] || 0; + } + // Only check for optimizations if EVs are completed + if (origLeftoverEVs > 4) return null; + + // Can't move the plus if it boosts its stat past normal EV limit + const plusTooHigh = origNature.plus && getStat(origNature.plus, 252, {}) < origStats[origNature.plus]; + // Can't move the minus if there's no investment in its stat to redistribute + const minusTooLow = origNature.minus && !origSpread.evs?.[origNature.minus]; + // If we can't move either of them, do nothing + if (plusTooHigh && minusTooLow) return null; + + let bestPlus = origNature.plus; + let bestPlusMinEVs = bestPlus && origSpread.evs[bestPlus]; + let bestMinus = origNature.minus || 'atk'; + let bestMinusMinEVs = origSpread.evs[bestMinus]; + let savedEVs = 0; + + // Try and move the minus first, as figuring out where the plus should go is harder if the minus hasn't been placed + if (!minusTooLow) { + for (const stat of Dex.statNamesExceptHP) { + if (origStats[stat] < origStats[bestMinus]) { + const minEVs = getMinEVs(stat, {minus: stat}); + if (minEVs > 252) continue; + // This number can go negative at this point, but we'll make up for it later (and check to make sure) + savedEVs = (origSpread.evs[stat] || 0) - minEVs; + if (origNature.minus) { + savedEVs += (origSpread.evs[origNature.minus] || 0) - getMinEVs(origNature.minus, {minus: stat}); + } + bestMinus = stat; + bestMinusMinEVs = minEVs; + } + } + } + if (!plusTooHigh) { + for (const stat of Dex.statNamesExceptHP) { + // Don't move the plus to an uninvested stat + if (stat !== origNature.plus && origSpread.evs[stat] && stat !== bestMinus) { + const minEVs = getMinEVs(stat, {plus: stat}); + let plusEVsSaved = (origNature.minus === stat ? getMinEVs(stat, {}) : origSpread.evs[stat] || 0) - minEVs; + if (bestPlus && bestPlus !== bestMinus) { + plusEVsSaved += bestPlusMinEVs! - getMinEVs(bestPlus, {plus: stat, minus: bestMinus}); + } + if (plusEVsSaved > 0 && savedEVs + plusEVsSaved > 0) { + savedEVs += plusEVsSaved; + bestPlus = stat; + bestPlusMinEVs = minEVs; + } else if (plusEVsSaved === 0 && (bestPlus || savedEVs > 0) || plusEVsSaved > 0 && savedEVs + plusEVsSaved === 0) { + if (!bestPlus || getStat(stat, getMinEVs(stat, {plus: stat}), {plus: stat}) > origStats[stat]) { + savedEVs += plusEVsSaved; + bestPlus = stat; + bestPlusMinEVs = minEVs; + } + } + } + } + } + + if (bestPlus && savedEVs >= 0) { + const newSpread: { + evs: Partial, + plus?: StatNameExceptHP, + minus?: StatNameExceptHP, + } = {evs: {...origSpread.evs}, plus: bestPlus, minus: bestMinus}; + if (bestPlus !== origNature.plus || bestMinus !== origNature.minus) { + if (bestPlusMinEVs) newSpread.evs[bestPlus] = bestPlusMinEVs; + if (bestMinusMinEVs) newSpread.evs[bestMinus] = bestMinusMinEVs; + if (origNature.plus && origNature.plus !== bestPlus && origNature.plus !== bestMinus) { + const oldPlusEVs = getMinEVs(origNature.plus, newSpread); + if (oldPlusEVs) newSpread.evs[origNature.plus] = oldPlusEVs; + } + if (origNature.minus && origNature.minus !== bestPlus && origNature.minus !== bestMinus) { + const oldMinusEVS = getMinEVs(origNature.minus, newSpread); + if (oldMinusEVS) newSpread.evs[origNature.minus] = oldMinusEVS; + } + for (const stat of Dex.statNames) { + if (!newSpread.evs[stat]) delete newSpread.evs[stat]; + } + return {...newSpread, savedEVs}; + } else if (!plusTooHigh && !minusTooLow) { + if (Math.floor(getStat(bestPlus, bestMinusMinEVs!, newSpread) / 11) <= Math.ceil(origStats[bestMinus] / 9)) { + // We're not gaining more points from our plus than we're losing to our minus + // So a neutral nature would be better + delete newSpread.plus; + delete newSpread.minus; + newSpread.evs[origNature.plus] = getMinEVs(origNature.plus, newSpread); + newSpread.evs[origNature.minus] = getMinEVs(origNature.minus, newSpread); + savedEVs += (origSpread.evs[origNature.plus] || 0) - newSpread.evs[origNature.plus]!; + savedEVs += (origSpread.evs[origNature.minus] || 0) - newSpread.evs[origNature.minus]!; + if (savedEVs < 0) return null; + for (const stat of Dex.statNames) { + if (!newSpread.evs[stat]) delete newSpread.evs[stat]; + } + return {...newSpread, savedEVs}; + } + } + } + + return null; +} + if (typeof require === 'function') { // in Node (global as any).BattleStatGuesser = BattleStatGuesser; + (global as any).BattleStatOptimizer = BattleStatOptimizer; } diff --git a/test/ev-optimizer.test.js b/test/ev-optimizer.test.js new file mode 100644 index 000000000..4f1035a6a --- /dev/null +++ b/test/ev-optimizer.test.js @@ -0,0 +1,105 @@ +const assert = require('assert').strict; + +try { + global.BattlePokedex = require('../play.pokemonshowdown.com/data/pokedex.js').BattlePokedex; +} catch (err) {} +require('../play.pokemonshowdown.com/js/battle-dex-data.js'); +require('../play.pokemonshowdown.com/js/battle-dex.js'); +require('../play.pokemonshowdown.com/js/battle-tooltips.js'); + +describe('EV Optimizer', () => { + (global.BattlePokedex ? it : it.skip)('should find the spreads that saves the most EVs', () => { + const trapinch = BattleStatOptimizer({ + species: "Trapinch", + nature: "Lax", + evs: {hp: 204, atk: 252, def: 52}, + level: 100 + }, 'gen9'); + assert.deepStrictEqual(trapinch, { + evs: {hp: 204, atk: 144, def: 104}, + plus: 'atk', + minus: 'spd', + savedEVs: 56, + }); + + const groudon = BattleStatOptimizer({ + species: "Groudon-Primal", + nature: "Serious", + evs: {atk: 252, spa: 156, spe: 100}, + level: 100 + }, 'gen7'); + assert.deepStrictEqual(groudon, { + evs: {atk: 88, spa: 156, spd: 96, spe: 100}, + plus: 'atk', + minus: 'spd', + savedEVs: 68, + }); + + const thundurus = BattleStatOptimizer({ + species: "Thundurus", + nature: "Timid", + evs: {hp: 252, spa: 232, spe: 24}, + ivs: {hp: 31, atk: 2, def: 31, spa: 30, spd: 31, spe: 30}, + level: 50 + }, 'gen5'); + assert.deepStrictEqual(thundurus, { + evs: {hp: 252, spa: 112, spe: 128}, + plus: 'spa', + minus: 'atk', + savedEVs: 16, + }); + + const amoonguss = BattleStatOptimizer({ + species: "Amoonguss", + nature: "Bold", + evs: {hp: 252, def: 100, spd: 156}, + level: 50 + }, 'gen9'); + assert.deepStrictEqual(amoonguss, { + evs: {hp: 252, def: 180, spd: 76}, + plus: 'spd', + minus: 'atk', + savedEVs: 0, + }); + + const avalugg = BattleStatOptimizer({ + species: "Avalugg", + nature: "Hasty", + evs: {hp: 56, atk: 200, def: 252}, + level: 100 + }, 'gen9'); + assert.deepStrictEqual(avalugg, { + evs: {hp: 56, atk: 84, def: 64, spe: 84}, + plus: 'atk', + minus: 'spe', + savedEVs: 220, + }); + + const mew = BattleStatOptimizer({ + species: "Mew", + nature: "Sassy", + evs: {hp: 24, atk: 92, def: 92, spa: 92, spd: 104, spe: 104}, + level: 100 + }, 'gen9'); + assert.deepStrictEqual(mew, { + evs: {hp: 24, atk: 92, def: 92, spa: 92, spd: 208}, + savedEVs: 0, + }); + + const mew2 = BattleStatOptimizer({ + species: "Mew", + nature: "Sassy", + evs: {hp: 24, atk: 92, def: 92, spa: 92, spd: 144, spe: 64}, + level: 100 + }, 'gen9'); + assert.equal(mew2, null); + + const mienfoo = BattleStatOptimizer({ + species: "Mienfoo", + nature: "Jolly", + evs: {atk: 236, def: 116, spe: 156}, + level: 5 + }, 'gen9'); + assert.equal(mienfoo, null); + }); +});