From 877bda4378cda550b3d1c09d2c60e4ef992a68b9 Mon Sep 17 00:00:00 2001
From: Karthik <32044378+Karthik99999@users.noreply.github.com>
Date: Fri, 5 Apr 2024 21:33:26 -0700
Subject: [PATCH] Teambuilder: Add EV optimization checker (#2240)
---------
Co-authored-by: pyuk-bot
---
.eslintrc.js | 2 +-
.../js/client-teambuilder.js | 46 +++++-
.../src/battle-dex-data.ts | 7 +-
.../src/battle-tooltips.ts | 150 +++++++++++++++++-
test/ev-optimizer.test.js | 105 ++++++++++++
5 files changed, 306 insertions(+), 4 deletions(-)
create mode 100644 test/ev-optimizer.test.js
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);
+ });
+});