Teambuilder: Add EV optimization checker (#2240)

---------

Co-authored-by: pyuk-bot <msaimrkon@gmail.com>
This commit is contained in:
Karthik
2024-04-05 21:33:26 -07:00
committed by GitHub
parent ae907b5d3a
commit 877bda4378
5 changed files with 306 additions and 4 deletions

View File

@@ -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

View File

@@ -2040,6 +2040,7 @@
}
}
this.$chart.find('select[name=nature]').val(set.nature || 'Serious');
this.checkStatOptimizations();
},
curChartType: '',
curChartName: '',
@@ -2465,15 +2466,58 @@
}
buf += '</select></p>';
buf += '<p><em>Protip:</em> You can also set natures by typing <kbd>+</kbd> and <kbd>-</kbd> next to a stat.</p>';
buf += '<p><small><em>Protip:</em> You can also set natures by typing <kbd>+</kbd> and <kbd>-</kbd> next to a stat.</small></p>';
buf += '<p id="statoptimizer"></p>';
}
buf += '</div>';
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 += '<small><em>Protip:</em> Use a different nature to ' + msg + ': </small>';
buf += ' <button name="setStatFormOptimization" class="button">';
for (var i in BattleStatNames) {
if (optimized.evs[i]) {
buf += '' + optimized.evs[i] + ' ' + BattleStatNames[i] + ' / ';
}
}
if (!optimized.plus && !optimized.minus) {
buf += ' (Neutral nature)';
} else {
buf += ' (+' + BattleStatNames[optimized.plus] + ', -' + BattleStatNames[optimized.minus] + ')';
}
buf += '</button>';
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);
},

View File

@@ -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',

View File

@@ -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<StatsTable>;
/** 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<StatsTable>,
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;
}

105
test/ev-optimizer.test.js Normal file
View File

@@ -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);
});
});