mirror of
https://github.com/smogon/pokemon-showdown-client.git
synced 2026-04-24 23:30:37 -05:00
This should be a much nicer architecture than the old `client-battle.js`. In particular, much of the logic of choosing moves/switches has been moved into a new `battle-choices.ts`, with `panel-battle.tsx` only covering the UI.
2400 lines
81 KiB
TypeScript
2400 lines
81 KiB
TypeScript
/**
|
|
* Pokemon Showdown Tooltips
|
|
*
|
|
* A file for generating tooltips for battles. This should be IE7+ and
|
|
* use the DOM directly.
|
|
*
|
|
* @author Guangcong Luo <guangcongluo@gmail.com>
|
|
* @license MIT
|
|
*/
|
|
|
|
class ModifiableValue {
|
|
value = 0;
|
|
maxValue = 0;
|
|
comment: string[];
|
|
battle: Battle;
|
|
pokemon: Pokemon | null;
|
|
serverPokemon: ServerPokemon;
|
|
itemName: string;
|
|
abilityName: string;
|
|
weatherName: string;
|
|
isAccuracy = false;
|
|
constructor(battle: Battle, pokemon: Pokemon | null, serverPokemon: ServerPokemon) {
|
|
this.comment = [];
|
|
this.battle = battle;
|
|
this.pokemon = pokemon;
|
|
this.serverPokemon = serverPokemon;
|
|
|
|
this.itemName = Dex.getItem(serverPokemon.item).name;
|
|
const ability = serverPokemon.ability || pokemon?.ability || serverPokemon.baseAbility;
|
|
this.abilityName = Dex.getAbility(ability).name;
|
|
this.weatherName = Dex.getMove(battle.weather).exists ?
|
|
Dex.getMove(battle.weather).name : Dex.getAbility(battle.weather).name;
|
|
}
|
|
reset(value = 0, isAccuracy?: boolean) {
|
|
this.value = value;
|
|
this.maxValue = 0;
|
|
this.isAccuracy = !!isAccuracy;
|
|
this.comment = [];
|
|
}
|
|
tryItem(itemName: string) {
|
|
if (itemName !== this.itemName) return false;
|
|
if (this.battle.hasPseudoWeather('Magic Room')) {
|
|
this.comment.push(` (${itemName} suppressed by Magic Room)`);
|
|
return false;
|
|
}
|
|
if (this.pokemon?.volatiles['embargo']) {
|
|
this.comment.push(` (${itemName} suppressed by Embargo)`);
|
|
return false;
|
|
}
|
|
const ignoreKlutz = [
|
|
"Macho Brace", "Power Anklet", "Power Band", "Power Belt", "Power Bracer", "Power Lens", "Power Weight",
|
|
];
|
|
if (this.tryAbility('Klutz') && !ignoreKlutz.includes(itemName)) {
|
|
this.comment.push(` (${itemName} suppressed by Klutz)`);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
tryAbility(abilityName: string) {
|
|
if (abilityName !== this.abilityName) return false;
|
|
if (this.pokemon?.volatiles['gastroacid']) {
|
|
this.comment.push(` (${abilityName} suppressed by Gastro Acid)`);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
tryWeather(weatherName?: string) {
|
|
if (!this.weatherName) return false;
|
|
if (!weatherName) weatherName = this.weatherName;
|
|
else if (weatherName !== this.weatherName) return false;
|
|
for (const side of this.battle.sides) {
|
|
for (const active of side.active) {
|
|
if (active && ['Air Lock', 'Cloud Nine'].includes(active.ability)) {
|
|
this.comment.push(` (${weatherName} suppressed by ${active.ability})`);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
itemModify(factor: number, itemName?: string) {
|
|
if (!itemName) itemName = this.itemName;
|
|
if (!itemName) return false;
|
|
if (!this.tryItem(itemName)) return false;
|
|
return this.modify(factor, itemName);
|
|
}
|
|
abilityModify(factor: number, abilityName: string) {
|
|
if (!this.tryAbility(abilityName)) return false;
|
|
return this.modify(factor, abilityName);
|
|
}
|
|
weatherModify(factor: number, weatherName?: string, name?: string) {
|
|
if (!weatherName) weatherName = this.weatherName;
|
|
if (!weatherName) return false;
|
|
if (!this.tryWeather(weatherName)) return false;
|
|
return this.modify(factor, name || weatherName);
|
|
}
|
|
modify(factor: number, name?: string) {
|
|
if (factor === 0) {
|
|
if (name) this.comment.push(` (${name})`);
|
|
this.value = 0;
|
|
this.maxValue = 0;
|
|
return true;
|
|
}
|
|
if (name) this.comment.push(` (${this.round(factor)}× from ${name})`);
|
|
this.value *= factor;
|
|
if (!(name === 'Technician' && this.maxValue > 60)) this.maxValue *= factor;
|
|
return true;
|
|
}
|
|
set(value: number, reason?: string) {
|
|
if (reason) this.comment.push(` (${reason})`);
|
|
this.value = value;
|
|
this.maxValue = 0;
|
|
return true;
|
|
}
|
|
setRange(value: number, maxValue: number, reason?: string) {
|
|
if (reason) this.comment.push(` (${reason})`);
|
|
this.value = value;
|
|
this.maxValue = maxValue;
|
|
return true;
|
|
}
|
|
round(value: number) {
|
|
return value ? Number(value.toFixed(2)) : 0;
|
|
}
|
|
toString() {
|
|
let valueString;
|
|
if (this.isAccuracy) {
|
|
valueString = this.value ? `${this.round(this.value)}%` : `can't miss`;
|
|
} else {
|
|
valueString = this.value ? `${this.round(this.value)}` : ``;
|
|
}
|
|
if (this.maxValue) {
|
|
valueString += ` to ${this.round(this.maxValue)}` + (this.isAccuracy ? '%' : '');
|
|
}
|
|
return valueString + this.comment.join('');
|
|
}
|
|
}
|
|
|
|
class BattleTooltips {
|
|
battle: Battle;
|
|
|
|
constructor(battle: Battle) {
|
|
this.battle = battle;
|
|
}
|
|
|
|
// tooltips
|
|
// Touch delay, pressing finger more than that time will cause the tooltip to open.
|
|
// Shorter time will cause the button to click
|
|
static LONG_TAP_DELAY = 350; // ms
|
|
static longTapTimeout = 0;
|
|
static elem: HTMLDivElement | null = null;
|
|
static parentElem: HTMLElement | null = null;
|
|
static isLocked = false;
|
|
static isPressed = false;
|
|
|
|
static hideTooltip() {
|
|
if (!BattleTooltips.elem) return;
|
|
BattleTooltips.cancelLongTap();
|
|
BattleTooltips.elem.parentNode!.removeChild(BattleTooltips.elem);
|
|
BattleTooltips.elem = null;
|
|
BattleTooltips.parentElem = null;
|
|
BattleTooltips.isLocked = false;
|
|
$('#tooltipwrapper').removeClass('tooltip-locked');
|
|
}
|
|
|
|
static cancelLongTap() {
|
|
if (BattleTooltips.longTapTimeout) {
|
|
clearTimeout(BattleTooltips.longTapTimeout);
|
|
BattleTooltips.longTapTimeout = 0;
|
|
}
|
|
}
|
|
|
|
lockTooltip() {
|
|
if (BattleTooltips.elem && !BattleTooltips.isLocked) {
|
|
BattleTooltips.isLocked = true;
|
|
if (BattleTooltips.isPressed) {
|
|
$(BattleTooltips.parentElem!).removeClass('pressed');
|
|
BattleTooltips.isPressed = false;
|
|
}
|
|
$('#tooltipwrapper').addClass('tooltip-locked');
|
|
}
|
|
}
|
|
|
|
handleTouchEnd(e: TouchEvent) {
|
|
BattleTooltips.cancelLongTap();
|
|
|
|
if (!BattleTooltips.isLocked) BattleTooltips.hideTooltip();
|
|
}
|
|
|
|
listen(elem: HTMLElement | JQuery<HTMLElement>) {
|
|
const $elem = $(elem);
|
|
$elem.on('mouseover', '.has-tooltip', this.showTooltipEvent);
|
|
$elem.on('click', '.has-tooltip', this.clickTooltipEvent);
|
|
$elem.on('focus', '.has-tooltip', this.showTooltipEvent);
|
|
$elem.on('mouseout', '.has-tooltip', BattleTooltips.unshowTooltip);
|
|
$elem.on('mousedown', '.has-tooltip', this.holdLockTooltipEvent);
|
|
$elem.on('blur', '.has-tooltip', BattleTooltips.unshowTooltip);
|
|
$elem.on('mouseup', '.has-tooltip', BattleTooltips.unshowTooltip);
|
|
|
|
$elem.on('touchstart', '.has-tooltip', e => {
|
|
e.preventDefault();
|
|
this.holdLockTooltipEvent(e);
|
|
if (e.currentTarget === BattleTooltips.parentElem && BattleTooltips.parentElem!.tagName === 'BUTTON') {
|
|
$(BattleTooltips.parentElem!).addClass('pressed');
|
|
BattleTooltips.isPressed = true;
|
|
}
|
|
});
|
|
$elem.on('touchend', '.has-tooltip', e => {
|
|
e.preventDefault();
|
|
if (e.currentTarget === BattleTooltips.parentElem && BattleTooltips.isPressed) {
|
|
BattleTooltips.parentElem!.click();
|
|
}
|
|
BattleTooltips.unshowTooltip();
|
|
});
|
|
$elem.on('touchleave', '.has-tooltip', BattleTooltips.unshowTooltip);
|
|
$elem.on('touchcancel', '.has-tooltip', BattleTooltips.unshowTooltip);
|
|
}
|
|
|
|
clickTooltipEvent = (e: Event) => {
|
|
if (BattleTooltips.isLocked) {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
}
|
|
};
|
|
/**
|
|
* An event that will lock a tooltip if held down
|
|
*
|
|
* (Namely, a long-tap or long-click)
|
|
*/
|
|
holdLockTooltipEvent = (e: JQuery.TriggeredEvent) => {
|
|
if (BattleTooltips.isLocked) BattleTooltips.hideTooltip();
|
|
const target = e.currentTarget as HTMLElement;
|
|
this.showTooltip(target);
|
|
let factor = (e.type === 'mousedown' && target.tagName === 'BUTTON' ? 2 : 1);
|
|
|
|
BattleTooltips.longTapTimeout = setTimeout(() => {
|
|
BattleTooltips.longTapTimeout = 0;
|
|
this.lockTooltip();
|
|
}, BattleTooltips.LONG_TAP_DELAY * factor);
|
|
};
|
|
|
|
showTooltipEvent = (e: Event) => {
|
|
if (BattleTooltips.isLocked) return;
|
|
this.showTooltip(e.currentTarget as HTMLElement);
|
|
};
|
|
|
|
/**
|
|
* Only hides tooltips if they're not locked
|
|
*/
|
|
static unshowTooltip() {
|
|
if (BattleTooltips.isLocked) return;
|
|
if (BattleTooltips.isPressed) {
|
|
$(BattleTooltips.parentElem!).removeClass('pressed');
|
|
BattleTooltips.isPressed = false;
|
|
}
|
|
BattleTooltips.hideTooltip();
|
|
}
|
|
|
|
showTooltip(elem: HTMLElement) {
|
|
const args = (elem.dataset.tooltip || '').split('|');
|
|
const [type] = args;
|
|
/**
|
|
* If false, we instead attach the tooltip above the parent element.
|
|
* This is important for the move/switch menus so the tooltip doesn't
|
|
* cover up buttons above the hovered button.
|
|
*/
|
|
const ownHeight = !!elem.dataset.ownheight;
|
|
|
|
let buf: string;
|
|
switch (type) {
|
|
case 'move':
|
|
case 'zmove':
|
|
case 'maxmove': { // move|MOVE|ACTIVEPOKEMON|[GMAXMOVE]
|
|
let move = this.battle.dex.getMove(args[1]);
|
|
let index = parseInt(args[2], 10);
|
|
let pokemon = this.battle.mySide.active[index];
|
|
let serverPokemon = this.battle.myPokemon![index];
|
|
let gmaxMove = args[3] ? this.battle.dex.getMove(args[3]) : undefined;
|
|
if (!pokemon) return false;
|
|
buf = this.showMoveTooltip(move, type, pokemon, serverPokemon, gmaxMove);
|
|
break;
|
|
}
|
|
|
|
case 'pokemon': { // pokemon|SIDE|POKEMON
|
|
// mouse over sidebar pokemon
|
|
// pokemon definitely exists, serverPokemon always ignored
|
|
let sideIndex = parseInt(args[1], 10);
|
|
let side = this.battle.sides[sideIndex];
|
|
let pokemon = side.pokemon[parseInt(args[2], 10)];
|
|
if (args[3] === 'illusion') {
|
|
buf = '';
|
|
const species = pokemon.getBaseTemplate().baseSpecies;
|
|
let index = 1;
|
|
for (const otherPokemon of side.pokemon) {
|
|
if (otherPokemon.getBaseTemplate().baseSpecies === species) {
|
|
buf += this.showPokemonTooltip(otherPokemon, null, false, index);
|
|
index++;
|
|
}
|
|
}
|
|
} else {
|
|
buf = this.showPokemonTooltip(pokemon);
|
|
}
|
|
break;
|
|
}
|
|
case 'activepokemon': { // activepokemon|SIDE|ACTIVE
|
|
// mouse over active pokemon
|
|
// pokemon definitely exists, serverPokemon maybe
|
|
let sideIndex = parseInt(args[1], 10);
|
|
let side = this.battle.sides[sideIndex];
|
|
let activeIndex = parseInt(args[2], 10);
|
|
let pokemon = side.active[activeIndex];
|
|
let serverPokemon = null;
|
|
if (sideIndex === 0 && this.battle.myPokemon) {
|
|
serverPokemon = this.battle.myPokemon[activeIndex];
|
|
}
|
|
if (!pokemon) return false;
|
|
buf = this.showPokemonTooltip(pokemon, serverPokemon, true);
|
|
break;
|
|
}
|
|
case 'switchpokemon': { // switchpokemon|POKEMON
|
|
// mouse over switchable pokemon
|
|
// serverPokemon definitely exists, sidePokemon maybe
|
|
let side = this.battle.sides[0];
|
|
let activeIndex = parseInt(args[1], 10);
|
|
let pokemon = null;
|
|
if (activeIndex < side.active.length) {
|
|
pokemon = side.active[activeIndex];
|
|
}
|
|
let serverPokemon = this.battle.myPokemon![activeIndex];
|
|
buf = this.showPokemonTooltip(pokemon, serverPokemon);
|
|
break;
|
|
}
|
|
default:
|
|
throw new Error(`unrecognized type`);
|
|
}
|
|
|
|
let offset = {
|
|
left: 150,
|
|
top: 500,
|
|
};
|
|
if (elem) offset = $(elem).offset()!;
|
|
let x = offset.left - 2;
|
|
if (elem) {
|
|
offset = (ownHeight ? $(elem) : $(elem).parent()).offset()!;
|
|
}
|
|
let y = offset.top - 5;
|
|
|
|
if (y < 140) y = 140;
|
|
// if (x > room.leftWidth + 335) x = room.leftWidth + 335;
|
|
if (x > $(window).width()! - 305) x = Math.max($(window).width()! - 305, 0);
|
|
if (x < 0) x = 0;
|
|
|
|
let $wrapper = $('#tooltipwrapper');
|
|
if (!$wrapper.length) {
|
|
$wrapper = $(`<div id="tooltipwrapper" role="tooltip"></div>`);
|
|
$(document.body).append($wrapper);
|
|
$wrapper.on('click', e => {
|
|
try {
|
|
const selection = window.getSelection()!;
|
|
if (selection.type === 'Range') return;
|
|
} catch (err) {}
|
|
BattleTooltips.hideTooltip();
|
|
});
|
|
} else {
|
|
$wrapper.removeClass('tooltip-locked');
|
|
}
|
|
$wrapper.css({
|
|
left: x,
|
|
top: y,
|
|
});
|
|
buf = `<div class="tooltipinner"><div class="tooltip">${buf}</div></div>`;
|
|
$wrapper.html(buf).appendTo(document.body);
|
|
BattleTooltips.elem = $wrapper.find('.tooltip')[0] as HTMLDivElement;
|
|
BattleTooltips.isLocked = false;
|
|
if (elem) {
|
|
let height = $(BattleTooltips.elem).height()!;
|
|
if (height > y) {
|
|
y += height + 10;
|
|
if (ownHeight) y += $(elem).height()!;
|
|
else y += $(elem).parent().height()!;
|
|
y = Math.min(y, document.documentElement.clientHeight);
|
|
$wrapper.css('top', y);
|
|
}
|
|
}
|
|
BattleTooltips.parentElem = elem;
|
|
return true;
|
|
}
|
|
|
|
hideTooltip() {
|
|
BattleTooltips.hideTooltip();
|
|
}
|
|
|
|
static zMoveEffects: {[zEffect: string]: string} = {
|
|
'clearnegativeboost': "Restores negative stat stages to 0",
|
|
'crit2': "Crit ratio +2",
|
|
'heal': "Restores HP 100%",
|
|
'curse': "Restores HP 100% if user is Ghost type, otherwise Attack +1",
|
|
'redirect': "Redirects opposing attacks to user",
|
|
'healreplacement': "Restores replacement's HP 100%",
|
|
};
|
|
|
|
getStatusZMoveEffect(move: Move) {
|
|
if (move.zMoveEffect in BattleTooltips.zMoveEffects) {
|
|
return BattleTooltips.zMoveEffects[move.zMoveEffect];
|
|
}
|
|
let boostText = '';
|
|
if (move.zMoveBoost) {
|
|
let boosts = Object.keys(move.zMoveBoost) as StatName[];
|
|
boostText = boosts.map(stat =>
|
|
BattleStats[stat] + ' +' + move.zMoveBoost![stat]
|
|
).join(', ');
|
|
}
|
|
return boostText;
|
|
}
|
|
|
|
static zMoveTable: {[type in TypeName]: string} = {
|
|
Poison: "Acid Downpour",
|
|
Fighting: "All-Out Pummeling",
|
|
Dark: "Black Hole Eclipse",
|
|
Grass: "Bloom Doom",
|
|
Normal: "Breakneck Blitz",
|
|
Rock: "Continental Crush",
|
|
Steel: "Corkscrew Crash",
|
|
Dragon: "Devastating Drake",
|
|
Electric: "Gigavolt Havoc",
|
|
Water: "Hydro Vortex",
|
|
Fire: "Inferno Overdrive",
|
|
Ghost: "Never-Ending Nightmare",
|
|
Bug: "Savage Spin-Out",
|
|
Psychic: "Shattered Psyche",
|
|
Ice: "Subzero Slammer",
|
|
Flying: "Supersonic Skystrike",
|
|
Ground: "Tectonic Rage",
|
|
Fairy: "Twinkle Tackle",
|
|
"???": "",
|
|
};
|
|
|
|
static maxMoveTable: {[type in TypeName]: string} = {
|
|
Poison: "Max Ooze",
|
|
Fighting: "Max Knuckle",
|
|
Dark: "Max Darkness",
|
|
Grass: "Max Overgrowth",
|
|
Normal: "Max Strike",
|
|
Rock: "Max Rockfall",
|
|
Steel: "Max Steelspike",
|
|
Dragon: "Max Wyrmwind",
|
|
Electric: "Max Lightning",
|
|
Water: "Max Geyser",
|
|
Fire: "Max Flare",
|
|
Ghost: "Max Phantasm",
|
|
Bug: "Max Flutterby",
|
|
Psychic: "Max Mindstorm",
|
|
Ice: "Max Hailstorm",
|
|
Flying: "Max Airstream",
|
|
Ground: "Max Quake",
|
|
Fairy: "Max Starfall",
|
|
"???": "",
|
|
};
|
|
|
|
showMoveTooltip(move: Move, isZOrMax: string, pokemon: Pokemon, serverPokemon: ServerPokemon, gmaxMove?: Move) {
|
|
let text = '';
|
|
|
|
let zEffect = '';
|
|
let foeActive = pokemon.side.foe.active;
|
|
// TODO: move this somewhere it makes more sense
|
|
if (pokemon.ability === '(suppressed)') serverPokemon.ability = '(suppressed)';
|
|
let ability = toID(serverPokemon.ability || pokemon.ability || serverPokemon.baseAbility);
|
|
|
|
let value = new ModifiableValue(this.battle, pokemon, serverPokemon);
|
|
|
|
if (isZOrMax === 'zmove') {
|
|
let item = this.battle.dex.getItem(serverPokemon.item);
|
|
if (item.zMoveFrom === move.name) {
|
|
move = this.battle.dex.getMove(item.zMove as string);
|
|
} else if (move.category === 'Status') {
|
|
move = new Move(move.id, "", {
|
|
...move,
|
|
name: 'Z-' + move.name,
|
|
});
|
|
zEffect = this.getStatusZMoveEffect(move);
|
|
} else {
|
|
let moveName = BattleTooltips.zMoveTable[item.zMoveType as TypeName];
|
|
const zMove = this.battle.dex.getMove(moveName);
|
|
let movePower = move.zMovePower;
|
|
// the different Hidden Power types don't have a Z power set, fall back on base move
|
|
if (!movePower && move.id.startsWith('hiddenpower')) {
|
|
movePower = this.battle.dex.getMove('hiddenpower').zMovePower;
|
|
}
|
|
move = new Move(zMove.id, zMove.name, {
|
|
...zMove,
|
|
category: move.category,
|
|
basePower: movePower,
|
|
});
|
|
// TODO: Weather Ball type-changing shenanigans
|
|
}
|
|
} else if (isZOrMax === 'maxmove') {
|
|
if (move.category === 'Status') {
|
|
move = this.battle.dex.getMove('Max Guard');
|
|
} else {
|
|
// TODO look into if client knows if a pokemon (on its side) can gmax rather than dynamax.
|
|
// If not, tell client so we can use it for tooltips.
|
|
const maxMove = gmaxMove ? gmaxMove :
|
|
this.battle.dex.getMove(BattleTooltips.maxMoveTable[move.type as TypeName]);
|
|
move = new Move(maxMove.id, maxMove.name, {
|
|
...maxMove,
|
|
category: move.category,
|
|
basePower: move.gmaxPower,
|
|
});
|
|
}
|
|
}
|
|
|
|
text += '<h2>' + move.name + '<br />';
|
|
|
|
// Handle move type for moves that vary their type.
|
|
let [moveType, category] = this.getMoveType(move, value);
|
|
|
|
text += Dex.getTypeIcon(moveType);
|
|
text += ` <img src="${Dex.resourcePrefix}sprites/categories/${category}.png" alt="${category}" /></h2>`;
|
|
|
|
// Check if there are more than one active Pokémon to check for multiple possible BPs.
|
|
let showingMultipleBasePowers = false;
|
|
if (category !== 'Status' && foeActive.length > 1) {
|
|
// We check if there is a difference in base powers to note it.
|
|
// Otherwise, it is just shown as in singles.
|
|
// The trick is that we need to calculate it first for each Pokémon to see if it changes.
|
|
let prevBasePower: string | null = null;
|
|
let basePower: string = '';
|
|
let difference = false;
|
|
let basePowers = [];
|
|
for (const active of foeActive) {
|
|
if (!active) continue;
|
|
value = this.getMoveBasePower(move, moveType, value, active);
|
|
basePower = '' + value;
|
|
if (prevBasePower === null) prevBasePower = basePower;
|
|
if (prevBasePower !== basePower) difference = true;
|
|
basePowers.push('Base power vs ' + active.name + ': ' + basePower);
|
|
}
|
|
if (difference) {
|
|
text += '<p>' + basePowers.join('<br />') + '</p>';
|
|
showingMultipleBasePowers = true;
|
|
}
|
|
// Falls through to not to repeat code on showing the base power.
|
|
}
|
|
if (!showingMultipleBasePowers && category !== 'Status') {
|
|
let activeTarget = foeActive[0] || foeActive[1] || foeActive[2];
|
|
value = this.getMoveBasePower(move, moveType, value, activeTarget);
|
|
text += '<p>Base power: ' + value + '</p>';
|
|
}
|
|
|
|
let accuracy = this.getMoveAccuracy(move, value);
|
|
|
|
// Deal with Nature Power special case, indicating which move it calls.
|
|
if (move.id === 'naturepower') {
|
|
let calls;
|
|
if (this.battle.gen > 5) {
|
|
if (this.battle.hasPseudoWeather('Electric Terrain')) {
|
|
calls = 'Thunderbolt';
|
|
} else if (this.battle.hasPseudoWeather('Grassy Terrain')) {
|
|
calls = 'Energy Ball';
|
|
} else if (this.battle.hasPseudoWeather('Misty Terrain')) {
|
|
calls = 'Moonblast';
|
|
} else if (this.battle.hasPseudoWeather('Psychic Terrain')) {
|
|
calls = 'Psychic';
|
|
} else {
|
|
calls = 'Tri Attack';
|
|
}
|
|
} else if (this.battle.gen > 3) {
|
|
// In gens 4 and 5 it calls Earthquake.
|
|
calls = 'Earthquake';
|
|
} else {
|
|
// In gen 3 it calls Swift, so it retains its normal typing.
|
|
calls = 'Swift';
|
|
}
|
|
let calledMove = this.battle.dex.getMove(calls);
|
|
text += 'Calls ' + Dex.getTypeIcon(this.getMoveType(calledMove, value)[0]) + ' ' + calledMove.name;
|
|
}
|
|
|
|
text += '<p>Accuracy: ' + accuracy + '</p>';
|
|
if (zEffect) text += '<p>Z-Effect: ' + zEffect + '</p>';
|
|
|
|
if (this.battle.gen < 7 || this.battle.hardcoreMode) {
|
|
text += '<p class="section">' + move.shortDesc + '</p>';
|
|
} else {
|
|
text += '<p class="section">';
|
|
if (move.priority > 1) {
|
|
text += 'Nearly always moves first <em>(priority +' + move.priority + ')</em>.</p><p>';
|
|
} else if (move.priority <= -1) {
|
|
text += 'Nearly always moves last <em>(priority −' + (-move.priority) + ')</em>.</p><p>';
|
|
} else if (move.priority === 1) {
|
|
text += 'Usually moves first <em>(priority +' + move.priority + ')</em>.</p><p>';
|
|
}
|
|
|
|
text += '' + (move.desc || move.shortDesc) + '</p>';
|
|
|
|
if (this.battle.gameType === 'doubles') {
|
|
if (move.target === 'allAdjacent') {
|
|
text += '<p>◎ Hits both foes and ally.</p>';
|
|
} else if (move.target === 'allAdjacentFoes') {
|
|
text += '<p>◎ Hits both foes.</p>';
|
|
}
|
|
} else if (this.battle.gameType === 'triples') {
|
|
if (move.target === 'allAdjacent') {
|
|
text += '<p>◎ Hits adjacent foes and allies.</p>';
|
|
} else if (move.target === 'allAdjacentFoes') {
|
|
text += '<p>◎ Hits adjacent foes.</p>';
|
|
} else if (move.target === 'any') {
|
|
text += '<p>◎ Can target distant Pokémon in Triples.</p>';
|
|
}
|
|
}
|
|
|
|
if (move.flags.defrost) {
|
|
text += `<p class="movetag">The user thaws out if it is frozen.</p>`;
|
|
}
|
|
if (!move.flags.protect && !['self', 'allySide'].includes(move.target)) {
|
|
text += `<p class="movetag">Not blocked by Protect <small>(and Detect, King's Shield, Spiky Shield)</small></p>`;
|
|
}
|
|
if (move.flags.authentic) {
|
|
text += `<p class="movetag">Bypasses Substitute <small>(but does not break it)</small></p>`;
|
|
}
|
|
if (!move.flags.reflectable && !['self', 'allySide'].includes(move.target) && move.category === 'Status') {
|
|
text += `<p class="movetag">✓ Not bounceable <small>(can't be bounced by Magic Coat/Bounce)</small></p>`;
|
|
}
|
|
|
|
if (move.flags.contact) {
|
|
text += `<p class="movetag">✓ Contact <small>(triggers Iron Barbs, Spiky Shield, etc)</small></p>`;
|
|
}
|
|
if (move.flags.sound) {
|
|
text += `<p class="movetag">✓ Sound <small>(doesn't affect Soundproof pokemon)</small></p>`;
|
|
}
|
|
if (move.flags.powder) {
|
|
text += `<p class="movetag">✓ Powder <small>(doesn't affect Grass, Overcoat, Safety Goggles)</small></p>`;
|
|
}
|
|
if (move.flags.punch && ability === 'ironfist') {
|
|
text += `<p class="movetag">✓ Fist <small>(boosted by Iron Fist)</small></p>`;
|
|
}
|
|
if (move.flags.pulse && ability === 'megalauncher') {
|
|
text += `<p class="movetag">✓ Pulse <small>(boosted by Mega Launcher)</small></p>`;
|
|
}
|
|
if (move.flags.bite && ability === 'strongjaw') {
|
|
text += `<p class="movetag">✓ Bite <small>(boosted by Strong Jaw)</small></p>`;
|
|
}
|
|
if ((move.recoil || move.hasCustomRecoil) && ability === 'reckless') {
|
|
text += `<p class="movetag">✓ Recoil <small>(boosted by Reckless)</small></p>`;
|
|
}
|
|
if (move.flags.bullet) {
|
|
text += `<p class="movetag">✓ Bullet-like <small>(doesn't affect Bulletproof pokemon)</small></p>`;
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* Needs either a Pokemon or a ServerPokemon, but note that neither
|
|
* are guaranteed: If you hover over a possible switch-in that's
|
|
* never been switched in before, you'll only have a ServerPokemon,
|
|
* and if you hover over an opponent's pokemon, you'll only have a
|
|
* Pokemon.
|
|
*
|
|
* isActive is true if hovering over a pokemon in the battlefield,
|
|
* and false if hovering over a pokemon in the Switch menu.
|
|
*
|
|
* @param clientPokemon
|
|
* @param serverPokemon
|
|
* @param isActive
|
|
*/
|
|
showPokemonTooltip(
|
|
clientPokemon: Pokemon | null, serverPokemon?: ServerPokemon | null, isActive?: boolean, illusionIndex?: number
|
|
) {
|
|
const pokemon = clientPokemon || serverPokemon!;
|
|
let text = '';
|
|
let genderBuf = '';
|
|
const gender = pokemon.gender;
|
|
if (gender === 'M' || gender === 'F') {
|
|
genderBuf = ` <img src="${Dex.resourcePrefix}fx/gender-${gender.toLowerCase()}.png" alt="${gender}" width="7" height="10" class="pixelated" /> `;
|
|
}
|
|
|
|
let name = BattleLog.escapeHTML(pokemon.name);
|
|
if (pokemon.species !== pokemon.name) {
|
|
name += ' <small>(' + BattleLog.escapeHTML(pokemon.species) + ')</small>';
|
|
}
|
|
|
|
let levelBuf = (pokemon.level !== 100 ? ` <small>L${pokemon.level}</small>` : ``);
|
|
if (!illusionIndex || illusionIndex === 1) {
|
|
text += `<h2>${name}${genderBuf}${illusionIndex ? '' : levelBuf}<br />`;
|
|
|
|
if (clientPokemon?.volatiles.formechange) {
|
|
if (clientPokemon.volatiles.transform) {
|
|
text += `<small>(Transformed into ${clientPokemon.volatiles.formechange[1]})</small><br />`;
|
|
} else {
|
|
text += `<small>(Changed forme: ${clientPokemon.volatiles.formechange[1]})</small><br />`;
|
|
}
|
|
}
|
|
|
|
let types = this.getPokemonTypes(pokemon);
|
|
|
|
if (clientPokemon && (clientPokemon.volatiles.typechange || clientPokemon.volatiles.typeadd)) {
|
|
text += `<small>(Type changed)</small><br />`;
|
|
}
|
|
text += types.map(type => Dex.getTypeIcon(type)).join(' ');
|
|
text += `</h2>`;
|
|
}
|
|
|
|
if (illusionIndex) {
|
|
text += `<p class="section"><strong>Possible Illusion #${illusionIndex}</strong>${levelBuf}</p>`;
|
|
}
|
|
|
|
if (pokemon.fainted) {
|
|
text += '<p><small>HP:</small> (fainted)</p>';
|
|
} else if (this.battle.hardcoreMode) {
|
|
if (serverPokemon) {
|
|
text += '<p><small>HP:</small> ' + serverPokemon.hp + '/' + serverPokemon.maxhp + (pokemon.status ? ' <span class="status ' + pokemon.status + '">' + pokemon.status.toUpperCase() + '</span>' : '') + '</p>';
|
|
}
|
|
} else {
|
|
let exacthp = '';
|
|
if (serverPokemon) {
|
|
exacthp = ' (' + serverPokemon.hp + '/' + serverPokemon.maxhp + ')';
|
|
} else if (pokemon.maxhp === 48) {
|
|
exacthp = ' <small>(' + pokemon.hp + '/' + pokemon.maxhp + ' pixels)</small>';
|
|
}
|
|
text += '<p><small>HP:</small> ' + Pokemon.getHPText(pokemon) + exacthp + (pokemon.status ? ' <span class="status ' + pokemon.status + '">' + pokemon.status.toUpperCase() + '</span>' : '');
|
|
if (clientPokemon) {
|
|
if (pokemon.status === 'tox') {
|
|
if (pokemon.ability === 'Poison Heal' || pokemon.ability === 'Magic Guard') {
|
|
text += ' <small>Would take if ability removed: ' + Math.floor(100 / 16 * Math.min(clientPokemon.statusData.toxicTurns + 1, 15)) + '%</small>';
|
|
} else {
|
|
text += ' Next damage: ' + Math.floor(100 / 16 * Math.min(clientPokemon.statusData.toxicTurns + 1, 15)) + '%';
|
|
}
|
|
} else if (pokemon.status === 'slp') {
|
|
text += ' Turns asleep: ' + clientPokemon.statusData.sleepTurns;
|
|
}
|
|
}
|
|
text += '</p>';
|
|
}
|
|
|
|
const supportsAbilities = this.battle.gen > 2 && !this.battle.tier.includes("Let's Go");
|
|
|
|
let abilityText = '';
|
|
if (supportsAbilities) {
|
|
abilityText = this.getPokemonAbilityText(
|
|
clientPokemon, serverPokemon, isActive, !!illusionIndex && illusionIndex > 1
|
|
);
|
|
}
|
|
|
|
let itemText = '';
|
|
if (serverPokemon?.item) {
|
|
itemText = '<small>Item:</small> ' + Dex.getItem(serverPokemon.item).name;
|
|
} else if (clientPokemon) {
|
|
let item = '';
|
|
let itemEffect = clientPokemon.itemEffect || '';
|
|
if (clientPokemon.prevItem) {
|
|
item = 'None';
|
|
if (itemEffect) itemEffect += '; ';
|
|
let prevItem = Dex.getItem(clientPokemon.prevItem).name;
|
|
itemEffect += clientPokemon.prevItemEffect ? prevItem + ' was ' + clientPokemon.prevItemEffect : 'was ' + prevItem;
|
|
}
|
|
if (pokemon.item) item = Dex.getItem(pokemon.item).name;
|
|
if (itemEffect) itemEffect = ' (' + itemEffect + ')';
|
|
if (item) itemText = '<small>Item:</small> ' + item + itemEffect;
|
|
}
|
|
|
|
text += '<p>';
|
|
text += abilityText;
|
|
if (itemText) {
|
|
// ability/item on one line for your own switch tooltips, two lines everywhere else
|
|
text += (!isActive && serverPokemon ? ' / ' : '</p><p>');
|
|
text += itemText;
|
|
}
|
|
text += '</p>';
|
|
|
|
text += this.renderStats(clientPokemon, serverPokemon, !isActive);
|
|
|
|
if (serverPokemon && !isActive) {
|
|
// move list
|
|
text += `<p class="section">`;
|
|
const battlePokemon = clientPokemon || this.battle.findCorrespondingPokemon(pokemon);
|
|
for (const moveid of serverPokemon.moves) {
|
|
const move = Dex.getMove(moveid);
|
|
let moveName = `• ${move.name}`;
|
|
if (battlePokemon?.moveTrack) {
|
|
for (const row of battlePokemon.moveTrack) {
|
|
if (moveName === row[0]) {
|
|
moveName = this.getPPUseText(row, true);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
text += `${moveName}<br />`;
|
|
}
|
|
text += '</p>';
|
|
} else if (!this.battle.hardcoreMode && clientPokemon?.moveTrack.length) {
|
|
// move list (guessed)
|
|
text += `<p class="section">`;
|
|
for (const row of clientPokemon.moveTrack) {
|
|
text += `${this.getPPUseText(row)}<br />`;
|
|
}
|
|
if (clientPokemon.moveTrack.filter(([moveName]) =>
|
|
moveName.charAt(0) !== '*' && !this.battle.dex.getMove(moveName).isZ
|
|
).length > 4) {
|
|
text += `(More than 4 moves is usually a sign of Illusion Zoroark/Zorua.) `;
|
|
}
|
|
if (this.battle.gen === 3) {
|
|
text += `(Pressure is not visible in Gen 3, so in certain situations, more PP may have been lost than shown here.) `;
|
|
}
|
|
if (this.pokemonHasClones(clientPokemon)) {
|
|
text += `(Your opponent has two indistinguishable Pokémon, making it impossible for you to tell which one has which moves/ability/item.) `;
|
|
}
|
|
text += `</p>`;
|
|
}
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* Does this Pokémon's trainer have two of these Pokémon that are
|
|
* indistinguishable? (Same nickname, species, forme, level, gender,
|
|
* and shininess.)
|
|
*/
|
|
pokemonHasClones(pokemon: Pokemon) {
|
|
const side = pokemon.side;
|
|
if (side.battle.speciesClause) return false;
|
|
for (const ally of side.pokemon) {
|
|
if (pokemon !== ally && pokemon.searchid === ally.searchid) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
calculateModifiedStats(clientPokemon: Pokemon | null, serverPokemon: ServerPokemon) {
|
|
let stats = {...serverPokemon.stats};
|
|
let pokemon = clientPokemon || serverPokemon;
|
|
const isPowerTrick = clientPokemon?.volatiles['powertrick'];
|
|
for (const statName of Dex.statNamesExceptHP) {
|
|
let sourceStatName = statName;
|
|
if (isPowerTrick) {
|
|
if (statName === 'atk') sourceStatName = 'def';
|
|
if (statName === 'def') sourceStatName = 'atk';
|
|
}
|
|
stats[statName] = serverPokemon.stats[sourceStatName];
|
|
if (!clientPokemon) continue;
|
|
|
|
const clientStatName = clientPokemon.boosts.spc && (statName === 'spa' || statName === 'spd') ? 'spc' : statName;
|
|
const boostLevel = clientPokemon.boosts[clientStatName];
|
|
if (boostLevel) {
|
|
let boostTable = [1, 1.5, 2, 2.5, 3, 3.5, 4];
|
|
if (boostLevel > 0) {
|
|
stats[statName] *= boostTable[boostLevel];
|
|
} else {
|
|
if (this.battle.gen <= 2) boostTable = [1, 100 / 66, 2, 2.5, 100 / 33, 100 / 28, 4];
|
|
stats[statName] /= boostTable[-boostLevel];
|
|
}
|
|
stats[statName] = Math.floor(stats[statName]);
|
|
}
|
|
}
|
|
|
|
let ability = toID(serverPokemon.ability || pokemon.ability || serverPokemon.baseAbility);
|
|
if (clientPokemon && 'gastroacid' in clientPokemon.volatiles) ability = '' as ID;
|
|
|
|
// check for burn, paralysis, guts, quick feet
|
|
if (pokemon.status) {
|
|
if (this.battle.gen > 2 && ability === 'guts') {
|
|
stats.atk = Math.floor(stats.atk * 1.5);
|
|
} else if (this.battle.gen < 2 && pokemon.status === 'brn') {
|
|
stats.atk = Math.floor(stats.atk * 0.5);
|
|
}
|
|
|
|
if (this.battle.gen > 2 && ability === 'quickfeet') {
|
|
stats.spe = Math.floor(stats.spe * 1.5);
|
|
} else if (pokemon.status === 'par') {
|
|
if (this.battle.gen > 6) {
|
|
stats.spe = Math.floor(stats.spe * 0.5);
|
|
} else {
|
|
stats.spe = Math.floor(stats.spe * 0.25);
|
|
}
|
|
}
|
|
}
|
|
|
|
// gen 1 doesn't support items
|
|
if (this.battle.gen <= 1) {
|
|
for (const statName of Dex.statNamesExceptHP) {
|
|
if (stats[statName] > 999) stats[statName] = 999;
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
let item = toID(serverPokemon.item);
|
|
if (ability === 'klutz' && item !== 'machobrace') item = '' as ID;
|
|
let species = Dex.getTemplate(clientPokemon ? clientPokemon.getSpecies() : serverPokemon.species).baseSpecies;
|
|
|
|
// check for light ball, thick club, metal/quick powder
|
|
// the only stat modifying items in gen 2 were light ball, thick club, metal powder
|
|
if (item === 'lightball' && species === 'Pikachu') {
|
|
if (this.battle.gen >= 4) stats.atk *= 2;
|
|
stats.spa *= 2;
|
|
}
|
|
|
|
if (item === 'thickclub') {
|
|
if (species === 'Marowak' || species === 'Cubone') {
|
|
stats.atk *= 2;
|
|
}
|
|
}
|
|
|
|
if (species === 'Ditto' && !(clientPokemon && 'transform' in clientPokemon.volatiles)) {
|
|
if (item === 'quickpowder') {
|
|
stats.spe *= 2;
|
|
}
|
|
if (item === 'metalpowder') {
|
|
if (this.battle.gen === 2) {
|
|
stats.def = Math.floor(stats.def * 1.5);
|
|
stats.spd = Math.floor(stats.spd * 1.5);
|
|
} else {
|
|
stats.def *= 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
// check abilities other than Guts and Quick Feet
|
|
// check items other than light ball, thick club, metal/quick powder
|
|
if (this.battle.gen <= 2) {
|
|
return stats;
|
|
}
|
|
|
|
let weather = this.battle.weather;
|
|
if (weather) {
|
|
// Check if anyone has an anti-weather ability
|
|
outer: for (const side of this.battle.sides) {
|
|
for (const active of side.active) {
|
|
if (active && ['Air Lock', 'Cloud Nine'].includes(active.ability)) {
|
|
weather = '' as ID;
|
|
break outer;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (item === 'choiceband' && !clientPokemon?.volatiles['dynamax']) {
|
|
stats.atk = Math.floor(stats.atk * 1.5);
|
|
}
|
|
if (ability === 'purepower' || ability === 'hugepower') {
|
|
stats.atk *= 2;
|
|
}
|
|
if (ability === 'hustle' || (ability === 'gorillatactics' && !clientPokemon?.volatiles['dynamax'])) {
|
|
stats.atk = Math.floor(stats.atk * 1.5);
|
|
}
|
|
if (weather) {
|
|
if (this.battle.gen >= 4 && this.pokemonHasType(serverPokemon, 'Rock') && weather === 'sandstorm') {
|
|
stats.spd = Math.floor(stats.spd * 1.5);
|
|
}
|
|
if (ability === 'sandrush' && weather === 'sandstorm') {
|
|
stats.spe *= 2;
|
|
}
|
|
if (ability === 'slushrush' && weather === 'hail') {
|
|
stats.spe *= 2;
|
|
}
|
|
if (item !== 'utilityumbrella') {
|
|
if (weather === 'sunnyday' || weather === 'desolateland') {
|
|
if (ability === 'solarpower') {
|
|
stats.spa = Math.floor(stats.spa * 1.5);
|
|
}
|
|
let allyActive = clientPokemon?.side.active;
|
|
if (allyActive) {
|
|
for (const ally of allyActive) {
|
|
if (!ally || ally.fainted) continue;
|
|
let allyAbility = this.getAllyAbility(ally);
|
|
if (allyAbility === 'Flower Gift' && (ally.getTemplate().baseSpecies === 'Cherrim' || this.battle.gen <= 4)) {
|
|
stats.atk = Math.floor(stats.atk * 1.5);
|
|
stats.spd = Math.floor(stats.spd * 1.5);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (ability === 'chlorophyll' && (weather === 'sunnyday' || weather === 'desolateland')) {
|
|
stats.spe *= 2;
|
|
}
|
|
if (ability === 'swiftswim' && (weather === 'raindance' || weather === 'primordialsea')) {
|
|
stats.spe *= 2;
|
|
}
|
|
}
|
|
}
|
|
if (ability === 'defeatist' && serverPokemon.hp <= serverPokemon.maxhp / 2) {
|
|
stats.atk = Math.floor(stats.atk * 0.5);
|
|
stats.spa = Math.floor(stats.spa * 0.5);
|
|
}
|
|
if (clientPokemon) {
|
|
if ('slowstart' in clientPokemon.volatiles) {
|
|
stats.atk = Math.floor(stats.atk * 0.5);
|
|
stats.spe = Math.floor(stats.spe * 0.5);
|
|
}
|
|
if (ability === 'unburden' && 'itemremoved' in clientPokemon.volatiles && !item) {
|
|
stats.spe *= 2;
|
|
}
|
|
}
|
|
if (ability === 'marvelscale' && pokemon.status) {
|
|
stats.def = Math.floor(stats.def * 1.5);
|
|
}
|
|
if (item === 'eviolite' && Dex.getTemplate(pokemon.species).evos) {
|
|
stats.def = Math.floor(stats.def * 1.5);
|
|
stats.spd = Math.floor(stats.spd * 1.5);
|
|
}
|
|
if (ability === 'grasspelt' && this.battle.hasPseudoWeather('Grassy Terrain')) {
|
|
stats.def = Math.floor(stats.def * 1.5);
|
|
}
|
|
if (ability === 'surgesurfer' && this.battle.hasPseudoWeather('Electric Terrain')) {
|
|
stats.spe *= 2;
|
|
}
|
|
if (item === 'choicespecs' && !clientPokemon?.volatiles['dynamax']) {
|
|
stats.spa = Math.floor(stats.spa * 1.5);
|
|
}
|
|
if (item === 'deepseatooth' && species === 'Clamperl') {
|
|
stats.spa *= 2;
|
|
}
|
|
if (item === 'souldew' && this.battle.gen <= 6 && (species === 'Latios' || species === 'Latias')) {
|
|
stats.spa = Math.floor(stats.spa * 1.5);
|
|
stats.spd = Math.floor(stats.spd * 1.5);
|
|
}
|
|
if (clientPokemon && (ability === 'plus' || ability === 'minus')) {
|
|
let allyActive = clientPokemon.side.active;
|
|
if (allyActive.length > 1) {
|
|
let abilityName = (ability === 'plus' ? 'Plus' : 'Minus');
|
|
for (const ally of allyActive) {
|
|
if (!ally || ally === clientPokemon || ally.fainted) continue;
|
|
let allyAbility = this.getAllyAbility(ally);
|
|
if (allyAbility !== 'Plus' && allyAbility !== 'Minus') continue;
|
|
if (this.battle.gen <= 4 && allyAbility === abilityName) continue;
|
|
stats.spa = Math.floor(stats.spa * 1.5);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (item === 'assaultvest') {
|
|
stats.spd = Math.floor(stats.spd * 1.5);
|
|
}
|
|
if (item === 'deepseascale' && species === 'Clamperl') {
|
|
stats.spd *= 2;
|
|
}
|
|
if (item === 'choicescarf' && !clientPokemon?.volatiles['dynamax']) {
|
|
stats.spe = Math.floor(stats.spe * 1.5);
|
|
}
|
|
if (item === 'ironball' || item === 'machobrace' || /power(?!herb)/.test(item)) {
|
|
stats.spe = Math.floor(stats.spe * 0.5);
|
|
}
|
|
if (ability === 'furcoat') {
|
|
stats.def *= 2;
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
|
|
renderStats(clientPokemon: Pokemon | null, serverPokemon?: ServerPokemon | null, short?: boolean) {
|
|
const isTransformed = clientPokemon?.volatiles.transform;
|
|
if (!serverPokemon || isTransformed) {
|
|
if (!clientPokemon) throw new Error('Must pass either clientPokemon or serverPokemon');
|
|
let [min, max] = this.getSpeedRange(clientPokemon);
|
|
return '<p><small>Spe</small> ' + min + ' to ' + max + ' <small>(before items/abilities/modifiers)</small></p>';
|
|
}
|
|
const stats = serverPokemon.stats;
|
|
const modifiedStats = this.calculateModifiedStats(clientPokemon, serverPokemon);
|
|
|
|
let buf = '<p>';
|
|
|
|
if (!short) {
|
|
let hasModifiedStat = false;
|
|
for (const statName of Dex.statNamesExceptHP) {
|
|
if (this.battle.gen === 1 && statName === 'spd') continue;
|
|
let statLabel = this.battle.gen === 1 && statName === 'spa' ? 'spc' : statName;
|
|
buf += statName === 'atk' ? '<small>' : '<small> / ';
|
|
buf += '' + BattleText[statLabel].statShortName + ' </small>';
|
|
buf += '' + stats[statName];
|
|
if (modifiedStats[statName] !== stats[statName]) hasModifiedStat = true;
|
|
}
|
|
buf += '</p>';
|
|
|
|
if (!hasModifiedStat) return buf;
|
|
|
|
buf += '<p><small>(After stat modifiers:)</small></p>';
|
|
buf += '<p>';
|
|
}
|
|
|
|
for (const statName of Dex.statNamesExceptHP) {
|
|
if (this.battle.gen === 1 && statName === 'spd') continue;
|
|
let statLabel = this.battle.gen === 1 && statName === 'spa' ? 'spc' : statName;
|
|
buf += statName === 'atk' ? '<small>' : '<small> / ';
|
|
buf += '' + BattleText[statLabel].statShortName + ' </small>';
|
|
if (modifiedStats[statName] === stats[statName]) {
|
|
buf += '' + modifiedStats[statName];
|
|
} else if (modifiedStats[statName] < stats[statName]) {
|
|
buf += '<strong class="stat-lowered">' + modifiedStats[statName] + '</strong>';
|
|
} else {
|
|
buf += '<strong class="stat-boosted">' + modifiedStats[statName] + '</strong>';
|
|
}
|
|
}
|
|
buf += '</p>';
|
|
return buf;
|
|
}
|
|
|
|
getPPUseText(moveTrackRow: [string, number], showKnown?: boolean) {
|
|
let [moveName, ppUsed] = moveTrackRow;
|
|
let move;
|
|
let maxpp;
|
|
if (moveName.charAt(0) === '*') {
|
|
// Transformed move
|
|
move = this.battle.dex.getMove(moveName.substr(1));
|
|
maxpp = 5;
|
|
} else {
|
|
move = this.battle.dex.getMove(moveName);
|
|
maxpp = move.noPPBoosts ? move.pp : Math.floor(move.pp * 8 / 5);
|
|
}
|
|
const bullet = moveName.charAt(0) === '*' || move.isZ ? '<span style="color:#888">•</span>' : '•';
|
|
if (ppUsed === Infinity) {
|
|
return `${bullet} ${move.name} <small>(0/${maxpp})</small>`;
|
|
}
|
|
if (ppUsed || moveName.charAt(0) === '*') {
|
|
return `${bullet} ${move.name} <small>(${maxpp - ppUsed}/${maxpp})</small>`;
|
|
}
|
|
return `${bullet} ${move.name} ${showKnown ? ' <small>(revealed)</small>' : ''}`;
|
|
}
|
|
|
|
ppUsed(move: Move, pokemon: Pokemon) {
|
|
for (let [moveName, ppUsed] of pokemon.moveTrack) {
|
|
if (moveName.charAt(0) === '*') moveName = moveName.substr(1);
|
|
if (move.name === moveName) return ppUsed;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Calculates possible Speed stat range of an opponent
|
|
*/
|
|
getSpeedRange(pokemon: Pokemon): [number, number] {
|
|
let level = pokemon.level;
|
|
let baseSpe = pokemon.getTemplate().baseStats['spe'];
|
|
let tier = this.battle.tier;
|
|
let gen = this.battle.gen;
|
|
let isRandomBattle = tier.includes('Random Battle') ||
|
|
(tier.includes('Random') && tier.includes('Battle') && gen >= 6);
|
|
|
|
let minNature = (isRandomBattle || gen < 3) ? 1 : 0.9;
|
|
let maxNature = (isRandomBattle || gen < 3) ? 1 : 1.1;
|
|
let maxIv = (gen < 3) ? 30 : 31;
|
|
|
|
let min;
|
|
let max;
|
|
const tr = Math.trunc || Math.floor;
|
|
if (tier.includes("Let's Go")) {
|
|
min = tr(tr(tr(2 * baseSpe * level / 100 + 5) * minNature) * tr((70 / 255 / 10 + 1) * 100) / 100);
|
|
max = tr(tr(tr((2 * baseSpe + maxIv) * level / 100 + 5) * maxNature) * tr((70 / 255 / 10 + 1) * 100) / 100);
|
|
if (tier.includes('No Restrictions')) max += 200;
|
|
else if (tier.includes('Random')) max += 20;
|
|
} else {
|
|
let maxIvEvOffset = maxIv + ((isRandomBattle && gen >= 3) ? 21 : 63);
|
|
min = tr(tr(2 * baseSpe * level / 100 + 5) * minNature);
|
|
max = tr(tr((2 * baseSpe + maxIvEvOffset) * level / 100 + 5) * maxNature);
|
|
}
|
|
return [min, max];
|
|
}
|
|
|
|
/**
|
|
* Gets the proper current type for moves with a variable type.
|
|
*/
|
|
getMoveType(move: Move, value: ModifiableValue): [TypeName, 'Physical' | 'Special' | 'Status'] {
|
|
let moveType = move.type;
|
|
let category = move.category;
|
|
// can happen in obscure situations
|
|
if (!value.pokemon) return [moveType, category];
|
|
|
|
let pokemonTypes = value.pokemon!.getTypeList(value.serverPokemon);
|
|
value.reset();
|
|
if (move.id === 'revelationdance') {
|
|
moveType = pokemonTypes[0];
|
|
}
|
|
// Moves that require an item to change their type.
|
|
let item = Dex.getItem(value.itemName);
|
|
if (move.id === 'multiattack' && item.onMemory) {
|
|
if (value.itemModify(0)) moveType = item.onMemory;
|
|
}
|
|
if (move.id === 'judgment' && item.onPlate && !item.zMoveType) {
|
|
if (value.itemModify(0)) moveType = item.onPlate;
|
|
}
|
|
if (move.id === 'technoblast' && item.onDrive) {
|
|
if (value.itemModify(0)) moveType = item.onDrive;
|
|
}
|
|
if (move.id === 'naturalgift' && item.naturalGift) {
|
|
if (value.itemModify(0)) moveType = item.naturalGift.type;
|
|
}
|
|
// Weather and pseudo-weather type changes.
|
|
if (move.id === 'weatherball' && value.weatherModify(0)) {
|
|
switch (this.battle.weather) {
|
|
case 'sunnyday':
|
|
case 'desolateland':
|
|
if (item.id === 'utilityumbrella') break;
|
|
moveType = 'Fire';
|
|
break;
|
|
case 'raindance':
|
|
case 'primordialsea':
|
|
if (item.id === 'utilityumbrella') break;
|
|
moveType = 'Water';
|
|
break;
|
|
case 'sandstorm':
|
|
moveType = 'Rock';
|
|
break;
|
|
case 'hail':
|
|
moveType = 'Ice';
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Aura Wheel as Morpeko-Hangry changes the type to Dark
|
|
if (move.id === 'aurawheel' && value.pokemon.getTemplate().species === 'Morpeko-Hangry') {
|
|
moveType = 'Dark';
|
|
}
|
|
|
|
// Other abilities that change the move type.
|
|
const noTypeOverride = [
|
|
'judgment', 'multiattack', 'naturalgift', 'revelationdance', 'struggle', 'technoblast', 'weatherball',
|
|
];
|
|
const allowTypeOverride = !noTypeOverride.includes(move.id);
|
|
|
|
if (allowTypeOverride && move.flags['sound'] && value.abilityModify(0, 'Liquid Voice')) {
|
|
moveType = 'Water';
|
|
}
|
|
if (allowTypeOverride && category !== 'Status') {
|
|
if (moveType === 'Normal') {
|
|
if (value.abilityModify(0, 'Aerilate')) moveType = 'Flying';
|
|
if (value.abilityModify(0, 'Galvanize')) moveType = 'Electric';
|
|
if (value.abilityModify(0, 'Pixilate')) moveType = 'Fairy';
|
|
if (value.abilityModify(0, 'Refrigerate')) moveType = 'Ice';
|
|
}
|
|
if (value.abilityModify(0, 'Normalize')) moveType = 'Normal';
|
|
}
|
|
if (this.battle.gen <= 3 && category !== 'Status') {
|
|
category = Dex.getGen3Category(moveType);
|
|
}
|
|
return [moveType, category];
|
|
}
|
|
|
|
// Gets the current accuracy for a move.
|
|
getMoveAccuracy(move: Move, value: ModifiableValue, target?: Pokemon) {
|
|
value.reset(move.accuracy === true ? 0 : move.accuracy, true);
|
|
|
|
let pokemon = value.pokemon!;
|
|
if (move.id === 'toxic' && this.battle.gen >= 6 && this.pokemonHasType(pokemon, 'Poison')) {
|
|
value.set(0, "Poison type");
|
|
return value;
|
|
}
|
|
if (move.id === 'blizzard') {
|
|
value.weatherModify(0, 'Hail');
|
|
}
|
|
if (move.id === 'hurricane' || move.id === 'thunder') {
|
|
value.weatherModify(0, 'Rain Dance');
|
|
value.weatherModify(0, 'Primordial Sea');
|
|
if (value.tryWeather('Sunny Day')) value.set(50, 'Sunny Day');
|
|
if (value.tryWeather('Desolate Land')) value.set(50, 'Desolate Land');
|
|
}
|
|
value.abilityModify(0, 'No Guard');
|
|
if (!value.value) return value;
|
|
if (move.ohko) {
|
|
if (this.battle.gen === 1) {
|
|
value.set(value.value, `fails if target's Speed is higher`);
|
|
return value;
|
|
}
|
|
if (move.id === 'sheercold' && this.battle.gen >= 7) {
|
|
if (!this.pokemonHasType(pokemon, 'Ice')) value.set(20, 'not Ice-type');
|
|
}
|
|
if (target) {
|
|
if (pokemon.level < target.level) {
|
|
value.reset(0);
|
|
value.set(0, "FAILS: target's level is higher");
|
|
} else if (pokemon.level > target.level) {
|
|
value.set(value.value + pokemon.level - target.level, "+1% per level above target");
|
|
}
|
|
} else {
|
|
if (pokemon.level < 100) value.set(value.value, "fails if target's level is higher");
|
|
if (pokemon.level > 1) value.set(value.value, "+1% per level above target");
|
|
}
|
|
return value;
|
|
}
|
|
if (pokemon?.boosts.accuracy) {
|
|
if (pokemon.boosts.accuracy > 0) {
|
|
value.modify((pokemon.boosts.accuracy + 3) / 3);
|
|
} else {
|
|
value.modify(3 / (3 - pokemon.boosts.accuracy));
|
|
}
|
|
}
|
|
if (move.category === 'Physical') {
|
|
value.abilityModify(0.8, "Hustle");
|
|
}
|
|
value.abilityModify(1.3, "Compound Eyes");
|
|
for (const active of pokemon.side.active) {
|
|
if (!active || active.fainted) continue;
|
|
let ability = this.getAllyAbility(active);
|
|
if (ability === 'Victory Star') {
|
|
value.modify(1.1, "Victory Star");
|
|
}
|
|
}
|
|
value.itemModify(1.1, "Wide Lens");
|
|
if (this.battle.hasPseudoWeather('Gravity')) {
|
|
value.modify(5 / 3, "Gravity");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// Gets the proper current base power for moves which have a variable base power.
|
|
// Takes into account the target for some moves.
|
|
// If it is unsure of the actual base power, it gives an estimate.
|
|
getMoveBasePower(move: Move, moveType: TypeName, value: ModifiableValue, target: Pokemon | null = null) {
|
|
const pokemon = value.pokemon!;
|
|
const serverPokemon = value.serverPokemon;
|
|
|
|
// apply modifiers for moves that depend on the actual stats
|
|
const modifiedStats = this.calculateModifiedStats(pokemon, serverPokemon);
|
|
|
|
value.reset(move.basePower);
|
|
|
|
if (move.id === 'acrobatics') {
|
|
if (!serverPokemon.item) {
|
|
value.modify(2, "Acrobatics + no item");
|
|
}
|
|
}
|
|
if (['crushgrip', 'wringout'].includes(move.id) && target) {
|
|
value.set(
|
|
Math.floor(Math.floor((120 * (100 * Math.floor(target.hp * 4096 / target.maxhp)) + 2048 - 1) / 4096) / 100) || 1,
|
|
'approximate'
|
|
);
|
|
}
|
|
if (move.id === 'brine' && target && target.hp * 2 <= target.maxhp) {
|
|
value.modify(2, 'Brine + target below half HP');
|
|
}
|
|
if (move.id === 'eruption' || move.id === 'waterspout') {
|
|
value.set(Math.floor(150 * pokemon.hp / pokemon.maxhp) || 1);
|
|
}
|
|
if (move.id === 'facade' && !['', 'slp', 'frz'].includes(pokemon.status)) {
|
|
value.modify(2, 'Facade + status');
|
|
}
|
|
if (move.id === 'flail' || move.id === 'reversal') {
|
|
let multiplier;
|
|
let ratios;
|
|
if (this.battle.gen > 4) {
|
|
multiplier = 48;
|
|
ratios = [2, 5, 10, 17, 33];
|
|
} else {
|
|
multiplier = 64;
|
|
ratios = [2, 6, 13, 22, 43];
|
|
}
|
|
let ratio = pokemon.hp * multiplier / pokemon.maxhp;
|
|
let basePower;
|
|
if (ratio < ratios[0]) basePower = 200;
|
|
else if (ratio < ratios[1]) basePower = 150;
|
|
else if (ratio < ratios[2]) basePower = 100;
|
|
else if (ratio < ratios[3]) basePower = 80;
|
|
else if (ratio < ratios[4]) basePower = 40;
|
|
else basePower = 20;
|
|
value.set(basePower);
|
|
}
|
|
if (move.id === 'hex' && target?.status) {
|
|
value.modify(2, 'Hex + status');
|
|
}
|
|
if (move.id === 'punishment' && target) {
|
|
let boostCount = 0;
|
|
for (const boost of Object.values(target.boosts)) {
|
|
if (boost > 0) boostCount += boost;
|
|
}
|
|
value.set(Math.min(60 + 20 * boostCount, 200));
|
|
}
|
|
if (move.id === 'smellingsalts' && target) {
|
|
if (target.status === 'par') {
|
|
value.modify(2, 'Smelling Salts + Paralysis');
|
|
}
|
|
}
|
|
if (['storedpower', 'powertrip'].includes(move.id) && target) {
|
|
let boostCount = 0;
|
|
for (const boost of Object.values(pokemon.boosts)) {
|
|
if (boost > 0) boostCount += boost;
|
|
}
|
|
value.set(20 + 20 * boostCount);
|
|
}
|
|
if (move.id === 'trumpcard') {
|
|
const ppLeft = 5 - this.ppUsed(move, pokemon);
|
|
let basePower = 40;
|
|
if (ppLeft === 1) basePower = 200;
|
|
else if (ppLeft === 2) basePower = 80;
|
|
else if (ppLeft === 3) basePower = 60;
|
|
else if (ppLeft === 4) basePower = 50;
|
|
value.set(basePower);
|
|
}
|
|
if (move.id === 'venoshock' && target) {
|
|
if (['psn', 'tox'].includes(target.status)) {
|
|
value.modify(2, 'Venoshock + Poison');
|
|
}
|
|
}
|
|
if (move.id === 'wakeupslap' && target) {
|
|
if (target.status === 'slp') {
|
|
value.modify(2, 'Wake-Up Slap + Sleep');
|
|
}
|
|
}
|
|
if (move.id === 'weatherball') {
|
|
value.weatherModify(2);
|
|
}
|
|
if (move.id === 'watershuriken' && pokemon.getSpecies() === 'Greninja-Ash' && pokemon.ability === 'Battle Bond') {
|
|
value.set(20, 'Battle Bond');
|
|
}
|
|
// Moves that check opponent speed
|
|
if (move.id === 'electroball' && target) {
|
|
let [minSpe, maxSpe] = this.getSpeedRange(target);
|
|
let minRatio = (modifiedStats.spe / maxSpe);
|
|
let maxRatio = (modifiedStats.spe / minSpe);
|
|
let min;
|
|
let max;
|
|
|
|
if (minRatio >= 4) min = 150;
|
|
else if (minRatio >= 3) min = 120;
|
|
else if (minRatio >= 2) min = 80;
|
|
else if (minRatio >= 1) min = 60;
|
|
else min = 40;
|
|
|
|
if (maxRatio >= 4) max = 150;
|
|
else if (maxRatio >= 3) max = 120;
|
|
else if (maxRatio >= 2) max = 80;
|
|
else if (maxRatio >= 1) max = 60;
|
|
else max = 40;
|
|
|
|
value.setRange(min, max);
|
|
}
|
|
if (move.id === 'gyroball' && target) {
|
|
let [minSpe, maxSpe] = this.getSpeedRange(target);
|
|
let min = (Math.floor(25 * minSpe / modifiedStats.spe) || 1);
|
|
if (min > 150) min = 150;
|
|
let max = (Math.floor(25 * maxSpe / modifiedStats.spe) || 1);
|
|
if (max > 150) max = 150;
|
|
value.setRange(min, max);
|
|
}
|
|
// Moves which have base power changed due to items
|
|
if (serverPokemon.item) {
|
|
let item = Dex.getItem(serverPokemon.item);
|
|
if (move.id === 'fling' && item.fling) {
|
|
value.itemModify(item.fling.basePower);
|
|
}
|
|
if (move.id === 'naturalgift') {
|
|
value.itemModify(item.naturalGift.basePower);
|
|
}
|
|
}
|
|
// Moves which have base power changed according to weight
|
|
if (['lowkick', 'grassknot', 'heavyslam', 'heatcrash'].includes(move.id)) {
|
|
let isGKLK = ['lowkick', 'grassknot'].includes(move.id);
|
|
if (target) {
|
|
let targetWeight = target.getWeightKg();
|
|
let pokemonWeight = pokemon.getWeightKg(serverPokemon);
|
|
let basePower;
|
|
if (isGKLK) {
|
|
basePower = 20;
|
|
if (targetWeight >= 200) basePower = 120;
|
|
else if (targetWeight >= 100) basePower = 100;
|
|
else if (targetWeight >= 50) basePower = 80;
|
|
else if (targetWeight >= 25) basePower = 60;
|
|
else if (targetWeight >= 10) basePower = 40;
|
|
} else {
|
|
basePower = 40;
|
|
if (pokemonWeight > targetWeight * 5) basePower = 120;
|
|
else if (pokemonWeight > targetWeight * 4) basePower = 100;
|
|
else if (pokemonWeight > targetWeight * 3) basePower = 80;
|
|
else if (pokemonWeight > targetWeight * 2) basePower = 60;
|
|
}
|
|
if (target.volatiles['dynamax']) {
|
|
value.set(0, 'blocked by target\'s Dynamax');
|
|
} else {
|
|
value.set(basePower);
|
|
}
|
|
} else {
|
|
value.setRange(isGKLK ? 20 : 40, 120);
|
|
}
|
|
}
|
|
if (!value.value) return value;
|
|
|
|
// Other ability boosts
|
|
if (pokemon.status === 'brn' && move.category === 'Special') {
|
|
value.abilityModify(1.5, "Flare Boost");
|
|
}
|
|
if (move.flags['pulse']) {
|
|
value.abilityModify(1.5, "Mega Launcher");
|
|
}
|
|
if (move.flags['bite']) {
|
|
value.abilityModify(1.5, "Strong Jaw");
|
|
}
|
|
if (value.value <= 60) {
|
|
value.abilityModify(1.5, "Technician");
|
|
}
|
|
if (['psn', 'tox'].includes(pokemon.status) && move.category === 'Physical') {
|
|
value.abilityModify(1.5, "Toxic Boost");
|
|
}
|
|
if (this.battle.gen > 2 && serverPokemon.status === 'brn' && move.id !== 'facade' && move.category === 'Physical') {
|
|
if (!value.tryAbility("Guts")) value.modify(0.5, 'Burn');
|
|
}
|
|
if (['Rock', 'Ground', 'Steel'].includes(moveType) && this.battle.weather === 'sandstorm') {
|
|
if (value.tryAbility("Sand Force")) value.weatherModify(1.3, "Sandstorm", "Sand Force");
|
|
}
|
|
if (move.secondaries) {
|
|
value.abilityModify(1.3, "Sheer Force");
|
|
}
|
|
if (move.flags['contact']) {
|
|
value.abilityModify(1.3, "Tough Claws");
|
|
}
|
|
if (moveType === 'Steel') {
|
|
value.abilityModify(1.5, "Steely Spirit");
|
|
}
|
|
if (move.flags['sound']) {
|
|
value.abilityModify(1.3, "Punk Rock");
|
|
}
|
|
if (target) {
|
|
if (["MF", "FM"].includes(pokemon.gender + target.gender)) {
|
|
value.abilityModify(0.75, "Rivalry");
|
|
} else if (["MM", "FF"].includes(pokemon.gender + target.gender)) {
|
|
value.abilityModify(1.25, "Rivalry");
|
|
}
|
|
}
|
|
const noTypeOverride = [
|
|
'judgment', 'multiattack', 'naturalgift', 'revelationdance', 'struggle', 'technoblast', 'weatherball',
|
|
];
|
|
if (move.category !== 'Status' && !noTypeOverride.includes(move.id)) {
|
|
if (move.type === 'Normal') {
|
|
value.abilityModify(this.battle.gen > 6 ? 1.2 : 1.3, "Aerilate");
|
|
value.abilityModify(this.battle.gen > 6 ? 1.2 : 1.3, "Galvanize");
|
|
value.abilityModify(this.battle.gen > 6 ? 1.2 : 1.3, "Pixilate");
|
|
value.abilityModify(this.battle.gen > 6 ? 1.2 : 1.3, "Refrigerate");
|
|
}
|
|
if (this.battle.gen > 6) {
|
|
value.abilityModify(1.2, "Normalize");
|
|
}
|
|
}
|
|
if (move.flags['punch']) {
|
|
value.abilityModify(1.2, 'Iron Fist');
|
|
}
|
|
if (move.recoil || move.hasCustomRecoil) {
|
|
value.abilityModify(1.2, 'Reckless');
|
|
}
|
|
|
|
if (move.category !== 'Status') {
|
|
let auraBoosted = '';
|
|
let auraBroken = false;
|
|
for (const ally of pokemon.side.active) {
|
|
if (!ally || ally.fainted) continue;
|
|
let allyAbility = this.getAllyAbility(ally);
|
|
if (moveType === 'Fairy' && allyAbility === 'Fairy Aura') {
|
|
auraBoosted = 'Fairy Aura';
|
|
} else if (moveType === 'Dark' && allyAbility === 'Dark Aura') {
|
|
auraBoosted = 'Dark Aura';
|
|
} else if (allyAbility === 'Aura Break') {
|
|
auraBroken = true;
|
|
} else if (allyAbility === 'Battery') {
|
|
if (ally !== pokemon && move.category === 'Special') {
|
|
value.modify(1.3, 'Battery');
|
|
}
|
|
} else if (allyAbility === 'Power Spot') {
|
|
if (ally !== pokemon) {
|
|
value.modify(1.3, 'Power Spot');
|
|
}
|
|
}
|
|
}
|
|
for (const foe of pokemon.side.foe.active) {
|
|
if (!foe || foe.fainted) continue;
|
|
if (foe.ability === 'Fairy Aura') {
|
|
if (moveType === 'Fairy') auraBoosted = 'Fairy Aura';
|
|
} else if (foe.ability === 'Dark Aura') {
|
|
if (moveType === 'Dark') auraBoosted = 'Dark Aura';
|
|
} else if (foe.ability === 'Aura Break') {
|
|
auraBroken = true;
|
|
}
|
|
}
|
|
if (auraBoosted) {
|
|
if (auraBroken) {
|
|
value.modify(0.75, auraBoosted + ' + Aura Break');
|
|
} else {
|
|
value.modify(1.33, auraBoosted);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Terrain
|
|
if ((this.battle.hasPseudoWeather('Electric Terrain') && moveType === 'Electric') ||
|
|
(this.battle.hasPseudoWeather('Grassy Terrain') && moveType === 'Grass') ||
|
|
(this.battle.hasPseudoWeather('Psychic Terrain') && moveType === 'Psychic')) {
|
|
if (pokemon.isGrounded(serverPokemon)) {
|
|
value.modify(this.battle.gen > 7 ? 1.3 : 1.5, 'Terrain boost');
|
|
}
|
|
} else if (this.battle.hasPseudoWeather('Misty Terrain') && moveType === 'Dragon') {
|
|
if (target ? target.isGrounded() : true) {
|
|
value.modify(0.5, 'Misty Terrain + grounded target');
|
|
}
|
|
}
|
|
|
|
// Item
|
|
value = this.getItemBoost(move, value, moveType);
|
|
|
|
return value;
|
|
}
|
|
|
|
static incenseTypes: {[itemName: string]: TypeName} = {
|
|
'Odd Incense': 'Psychic',
|
|
'Rock Incense': 'Rock',
|
|
'Rose Incense': 'Grass',
|
|
'Sea Incense': 'Water',
|
|
'Wave Incense': 'Water',
|
|
};
|
|
static itemTypes: {[itemName: string]: TypeName} = {
|
|
'Black Belt': 'Fighting',
|
|
'Black Glasses': 'Dark',
|
|
'Charcoal': 'Fire',
|
|
'Dragon Fang': 'Dragon',
|
|
'Hard Stone': 'Rock',
|
|
'Magnet': 'Electric',
|
|
'Metal Coat': 'Steel',
|
|
'Miracle Seed': 'Grass',
|
|
'Mystic Water': 'Water',
|
|
'Never-Melt Ice': 'Ice',
|
|
'Poison Barb': 'Poison',
|
|
'Sharp Beak': 'Flying',
|
|
'Silk Scarf': 'Normal',
|
|
'SilverPowder': 'Bug',
|
|
'Soft Sand': 'Ground',
|
|
'Spell Tag': 'Ghost',
|
|
'Twisted Spoon': 'Psychic',
|
|
};
|
|
static orbUsers: {[speciesName: string]: string} = {
|
|
'Latias': 'Soul Dew',
|
|
'Latios': 'Soul Dew',
|
|
'Dialga': 'Adamant Orb',
|
|
'Palkia': 'Lustrous Orb',
|
|
'Giratina': 'Griseous Orb',
|
|
};
|
|
static orbTypes: {[itemName: string]: TypeName} = {
|
|
'Soul Dew': 'Psychic',
|
|
'Adamant Orb': 'Steel',
|
|
'Lustrous Orb': 'Water',
|
|
'Griseous Orb': 'Ghost',
|
|
};
|
|
static noGemMoves = [
|
|
'Fire Pledge',
|
|
'Fling',
|
|
'Grass Pledge',
|
|
'Struggle',
|
|
'Water Pledge',
|
|
];
|
|
getItemBoost(move: Move, value: ModifiableValue, moveType: TypeName) {
|
|
let item = this.battle.dex.getItem(value.serverPokemon.item);
|
|
let itemName = item.name;
|
|
let moveName = move.name;
|
|
|
|
// Plates
|
|
if (item.onPlate === moveType && !item.zMove) {
|
|
value.itemModify(1.2);
|
|
return value;
|
|
}
|
|
|
|
// Incenses
|
|
if (BattleTooltips.incenseTypes[item.name] === moveType) {
|
|
value.itemModify(1.2);
|
|
return value;
|
|
}
|
|
|
|
// Type-enhancing items
|
|
if (BattleTooltips.itemTypes[item.name] === moveType) {
|
|
value.itemModify(this.battle.gen < 4 ? 1.1 : 1.2);
|
|
return value;
|
|
}
|
|
|
|
// Pokemon-specific items
|
|
if (item.name === 'Soul Dew' && this.battle.gen < 7) return value;
|
|
if (BattleTooltips.orbUsers[Dex.getTemplate(value.serverPokemon.species).baseSpecies] === item.name &&
|
|
[BattleTooltips.orbTypes[item.name], 'Dragon'].includes(moveType)) {
|
|
value.itemModify(1.2);
|
|
return value;
|
|
}
|
|
|
|
// Gems
|
|
if (BattleTooltips.noGemMoves.includes(moveName)) return value;
|
|
if (itemName === moveType + ' Gem') {
|
|
value.itemModify(this.battle.gen < 6 ? 1.5 : 1.3);
|
|
return value;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
getPokemonTypes(pokemon: Pokemon | ServerPokemon): ReadonlyArray<TypeName> {
|
|
if (!(pokemon as Pokemon).getTypes) {
|
|
return this.battle.dex.getTemplate(pokemon.species).types;
|
|
}
|
|
|
|
return (pokemon as Pokemon).getTypeList();
|
|
}
|
|
pokemonHasType(pokemon: Pokemon | ServerPokemon, type: TypeName, types?: ReadonlyArray<TypeName>) {
|
|
if (!types) types = this.getPokemonTypes(pokemon);
|
|
for (const curType of types) {
|
|
if (curType === type) return true;
|
|
}
|
|
return false;
|
|
}
|
|
getAllyAbility(ally: Pokemon) {
|
|
// this will only be available if the ability announced itself in some way
|
|
let allyAbility = Dex.getAbility(ally.ability).name;
|
|
// otherwise fall back on the original set data sent from the server
|
|
if (!allyAbility && this.battle.myPokemon) {
|
|
allyAbility = Dex.getAbility(this.battle.myPokemon[ally.slot].ability).name;
|
|
}
|
|
return allyAbility;
|
|
}
|
|
getPokemonAbilityData(clientPokemon: Pokemon | null, serverPokemon: ServerPokemon | null | undefined) {
|
|
const abilityData: {ability: string, baseAbility: string, possibilities: string[]} = {
|
|
ability: '', baseAbility: '', possibilities: [],
|
|
};
|
|
if (clientPokemon) {
|
|
if (clientPokemon.ability) {
|
|
abilityData.ability = clientPokemon.ability || clientPokemon.baseAbility;
|
|
if (clientPokemon.baseAbility) {
|
|
abilityData.baseAbility = clientPokemon.baseAbility;
|
|
}
|
|
} else {
|
|
const species = clientPokemon.getSpecies() || serverPokemon?.species || '';
|
|
const template = this.battle.dex.getTemplate(species);
|
|
if (template.exists && template.abilities) {
|
|
abilityData.possibilities = [template.abilities['0']];
|
|
if (template.abilities['1']) abilityData.possibilities.push(template.abilities['1']);
|
|
if (template.abilities['H']) abilityData.possibilities.push(template.abilities['H']);
|
|
if (template.abilities['S']) abilityData.possibilities.push(template.abilities['S']);
|
|
}
|
|
}
|
|
}
|
|
if (serverPokemon) {
|
|
if (!abilityData.ability) abilityData.ability = serverPokemon.ability || serverPokemon.baseAbility;
|
|
if (!abilityData.baseAbility && serverPokemon.baseAbility) {
|
|
abilityData.baseAbility = serverPokemon.baseAbility;
|
|
}
|
|
}
|
|
return abilityData;
|
|
}
|
|
getPokemonAbilityText(
|
|
clientPokemon: Pokemon | null,
|
|
serverPokemon: ServerPokemon | null | undefined,
|
|
isActive: boolean | undefined,
|
|
hidePossible?: boolean
|
|
) {
|
|
let text = '';
|
|
const abilityData = this.getPokemonAbilityData(clientPokemon, serverPokemon);
|
|
if (!isActive) {
|
|
// for switch tooltips, only show the original ability
|
|
const ability = abilityData.baseAbility || abilityData.ability;
|
|
if (ability) text = '<small>Ability:</small> ' + Dex.getAbility(ability).name;
|
|
} else {
|
|
if (abilityData.ability) {
|
|
const abilityName = Dex.getAbility(abilityData.ability).name;
|
|
text = '<small>Ability:</small> ' + abilityName;
|
|
const baseAbilityName = Dex.getAbility(abilityData.baseAbility).name;
|
|
if (baseAbilityName && baseAbilityName !== abilityName) text += ' (base: ' + baseAbilityName + ')';
|
|
}
|
|
}
|
|
if (!text && abilityData.possibilities.length && !hidePossible) {
|
|
text = '<small>Possible abilities:</small> ' + abilityData.possibilities.join(', ');
|
|
}
|
|
return text;
|
|
}
|
|
}
|
|
|
|
type StatsTable = {hp: number, atk: number, def: number, spa: number, spd: number, spe: number};
|
|
|
|
/**
|
|
* PokemonSet can be sparse, in which case that entry should be
|
|
* inferred from the rest of the set, according to sensible
|
|
* defaults.
|
|
*/
|
|
interface PokemonSet {
|
|
/** Defaults to species name (not including forme), like in games */
|
|
name?: string;
|
|
species: string;
|
|
/** Defaults to no item */
|
|
item?: string;
|
|
/** Defaults to no ability (error in Gen 3+) */
|
|
ability?: string;
|
|
moves: string[];
|
|
/** Defaults to no nature (error in Gen 3+) */
|
|
nature?: NatureName;
|
|
/** 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;
|
|
/** 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) */
|
|
level?: number;
|
|
/** Defaults to no (error if shiny event) */
|
|
shiny?: boolean;
|
|
/** Defaults to 255 unless you have Frustration, in which case 0 */
|
|
happiness?: number;
|
|
/** Defaults to event required ball, otherwise Poké Ball */
|
|
pokeball?: string;
|
|
/** Defaults to the type of your Hidden Power in Moves, otherwise Dark */
|
|
hpType?: string;
|
|
}
|
|
|
|
class BattleStatGuesser {
|
|
formatid: ID;
|
|
dex: ModdedDex;
|
|
moveCount: any = null;
|
|
hasMove: any = null;
|
|
|
|
ignoreEVLimits: boolean;
|
|
supportsEVs: boolean;
|
|
supportsAVs: boolean;
|
|
|
|
constructor(formatid: ID) {
|
|
this.formatid = formatid;
|
|
this.dex = formatid ? Dex.mod(formatid.slice(0, 4) as ID) : Dex;
|
|
this.ignoreEVLimits = (
|
|
this.dex.gen < 3 ||
|
|
this.formatid.endsWith('hackmons') ||
|
|
this.formatid === 'gen8metronomebattle' ||
|
|
this.formatid.endsWith('norestrictions')
|
|
);
|
|
this.supportsEVs = !this.formatid.startsWith('gen7letsgo');
|
|
this.supportsAVs = !this.supportsEVs && this.formatid.endsWith('norestrictions');
|
|
}
|
|
guess(set: PokemonSet) {
|
|
let role = this.guessRole(set);
|
|
let comboEVs = this.guessEVs(set, role);
|
|
let evs = {hp: 0, atk: 0, def: 0, spa: 0, spd: 0, spe: 0};
|
|
for (let stat in evs) {
|
|
evs[stat as StatName] = comboEVs[stat as StatName] || 0;
|
|
}
|
|
let plusStat = comboEVs.plusStat || '';
|
|
let minusStat = comboEVs.minusStat || '';
|
|
return {role, evs, plusStat, minusStat, moveCount: this.moveCount, hasMove: this.hasMove};
|
|
}
|
|
guessRole(set: PokemonSet) {
|
|
if (!set) return '?';
|
|
if (!set.moves) return '?';
|
|
|
|
let moveCount = {
|
|
'Physical': 0,
|
|
'Special': 0,
|
|
'PhysicalAttack': 0,
|
|
'SpecialAttack': 0,
|
|
'PhysicalSetup': 0,
|
|
'SpecialSetup': 0,
|
|
'Support': 0,
|
|
'Setup': 0,
|
|
'Restoration': 0,
|
|
'Offense': 0,
|
|
'Stall': 0,
|
|
'SpecialStall': 0,
|
|
'PhysicalStall': 0,
|
|
'Fast': 0,
|
|
'Ultrafast': 0,
|
|
'bulk': 0,
|
|
'specialBulk': 0,
|
|
'physicalBulk': 0,
|
|
};
|
|
let hasMove: {[moveid: string]: 1} = {};
|
|
let itemid = toID(set.item);
|
|
let item = this.dex.getItem(itemid);
|
|
let abilityid = toID(set.ability);
|
|
|
|
let template = this.dex.getTemplate(set.species || set.name!);
|
|
if (item.megaEvolves === template.species) template = this.dex.getTemplate(item.megaStone);
|
|
if (!template.exists) return '?';
|
|
let stats = template.baseStats;
|
|
|
|
if (set.moves.length < 1) return '?';
|
|
let needsFourMoves = !['unown', 'ditto'].includes(template.id);
|
|
let moveids = set.moves.map(toID);
|
|
if (moveids.includes('lastresort' as ID)) needsFourMoves = false;
|
|
if (set.moves.length < 4 && needsFourMoves && this.formatid !== 'gen8metronomebattle') {
|
|
return '?';
|
|
}
|
|
|
|
for (let i = 0, len = set.moves.length; i < len; i++) {
|
|
let move = Dex.getMove(set.moves[i]);
|
|
hasMove[move.id] = 1;
|
|
if (move.category === 'Status') {
|
|
if (['batonpass', 'healingwish', 'lunardance'].includes(move.id)) {
|
|
moveCount['Support']++;
|
|
} else if (['metronome', 'assist', 'copycat', 'mefirst'].includes(move.id)) {
|
|
moveCount['Physical'] += 0.5;
|
|
moveCount['Special'] += 0.5;
|
|
} else if (move.id === 'naturepower') {
|
|
moveCount['Special']++;
|
|
} else if (['protect', 'detect', 'spikyshield', 'kingsshield'].includes(move.id)) {
|
|
moveCount['Stall']++;
|
|
} else if (move.id === 'wish') {
|
|
moveCount['Restoration']++;
|
|
moveCount['Stall']++;
|
|
moveCount['Support']++;
|
|
} else if (move.heal) {
|
|
moveCount['Restoration']++;
|
|
moveCount['Stall']++;
|
|
} else if (move.target === 'self') {
|
|
if (['agility', 'rockpolish', 'shellsmash', 'growth', 'workup'].includes(move.id)) {
|
|
moveCount['PhysicalSetup']++;
|
|
moveCount['SpecialSetup']++;
|
|
} else if (['dragondance', 'swordsdance', 'coil', 'bulkup', 'curse', 'bellydrum'].includes(move.id)) {
|
|
moveCount['PhysicalSetup']++;
|
|
} else if (['nastyplot', 'tailglow', 'quiverdance', 'calmmind', 'geomancy'].includes(move.id)) {
|
|
moveCount['SpecialSetup']++;
|
|
}
|
|
if (move.id === 'substitute') moveCount['Stall']++;
|
|
moveCount['Setup']++;
|
|
} else {
|
|
if (['toxic', 'leechseed', 'willowisp'].includes(move.id)) {
|
|
moveCount['Stall']++;
|
|
}
|
|
moveCount['Support']++;
|
|
}
|
|
} else if (['counter', 'endeavor', 'metalburst', 'mirrorcoat', 'rapidspin'].includes(move.id)) {
|
|
moveCount['Support']++;
|
|
} else if ([
|
|
'nightshade', 'seismictoss', 'psywave', 'superfang', 'naturesmadness', 'foulplay', 'endeavor', 'finalgambit',
|
|
].includes(move.id)) {
|
|
moveCount['Offense']++;
|
|
} else if (move.id === 'fellstinger') {
|
|
moveCount['PhysicalSetup']++;
|
|
moveCount['Setup']++;
|
|
} else {
|
|
moveCount[move.category]++;
|
|
moveCount['Offense']++;
|
|
if (move.id === 'knockoff') {
|
|
moveCount['Support']++;
|
|
}
|
|
if (['scald', 'voltswitch', 'uturn'].includes(move.id)) {
|
|
moveCount[move.category] -= 0.2;
|
|
}
|
|
}
|
|
}
|
|
if (hasMove['batonpass']) moveCount['Support'] += moveCount['Setup'];
|
|
moveCount['PhysicalAttack'] = moveCount['Physical'];
|
|
moveCount['Physical'] += moveCount['PhysicalSetup'];
|
|
moveCount['SpecialAttack'] = moveCount['Special'];
|
|
moveCount['Special'] += moveCount['SpecialSetup'];
|
|
|
|
if (hasMove['dragondance'] || hasMove['quiverdance']) moveCount['Ultrafast'] = 1;
|
|
|
|
let isFast = (stats.spe >= 80);
|
|
let physicalBulk = (stats.hp + 75) * (stats.def + 87);
|
|
let specialBulk = (stats.hp + 75) * (stats.spd + 87);
|
|
|
|
if (hasMove['willowisp'] || hasMove['acidarmor'] || hasMove['irondefense'] || hasMove['cottonguard']) {
|
|
physicalBulk *= 1.6;
|
|
moveCount['PhysicalStall']++;
|
|
} else if (hasMove['scald'] || hasMove['bulkup'] || hasMove['coil'] || hasMove['cosmicpower']) {
|
|
physicalBulk *= 1.3;
|
|
if (hasMove['scald']) { // partial stall goes in reverse
|
|
moveCount['SpecialStall']++;
|
|
} else {
|
|
moveCount['PhysicalStall']++;
|
|
}
|
|
}
|
|
if (abilityid === 'flamebody') physicalBulk *= 1.1;
|
|
|
|
if (hasMove['calmmind'] || hasMove['quiverdance'] || hasMove['geomancy']) {
|
|
specialBulk *= 1.3;
|
|
moveCount['SpecialStall']++;
|
|
}
|
|
if (abilityid === 'sandstream' && template.types.includes('Rock')) {
|
|
specialBulk *= 1.5;
|
|
}
|
|
|
|
if (hasMove['bellydrum']) {
|
|
physicalBulk *= 0.6;
|
|
specialBulk *= 0.6;
|
|
}
|
|
if (moveCount['Restoration']) {
|
|
physicalBulk *= 1.5;
|
|
specialBulk *= 1.5;
|
|
} else if (hasMove['painsplit'] && hasMove['substitute']) {
|
|
// SubSplit isn't generally a stall set
|
|
moveCount['Stall']--;
|
|
} else if (hasMove['painsplit'] || hasMove['rest']) {
|
|
physicalBulk *= 1.4;
|
|
specialBulk *= 1.4;
|
|
}
|
|
if ((hasMove['bodyslam'] || hasMove['thunder']) && abilityid === 'serenegrace' || hasMove['thunderwave']) {
|
|
physicalBulk *= 1.1;
|
|
specialBulk *= 1.1;
|
|
}
|
|
if ((hasMove['ironhead'] || hasMove['airslash']) && abilityid === 'serenegrace') {
|
|
physicalBulk *= 1.1;
|
|
specialBulk *= 1.1;
|
|
}
|
|
if (hasMove['gigadrain'] || hasMove['drainpunch'] || hasMove['hornleech']) {
|
|
physicalBulk *= 1.15;
|
|
specialBulk *= 1.15;
|
|
}
|
|
if (itemid === 'leftovers' || itemid === 'blacksludge') {
|
|
physicalBulk *= 1 + 0.1 * (1 + moveCount['Stall'] / 1.5);
|
|
specialBulk *= 1 + 0.1 * (1 + moveCount['Stall'] / 1.5);
|
|
}
|
|
if (hasMove['leechseed']) {
|
|
physicalBulk *= 1 + 0.1 * (1 + moveCount['Stall'] / 1.5);
|
|
specialBulk *= 1 + 0.1 * (1 + moveCount['Stall'] / 1.5);
|
|
}
|
|
if ((itemid === 'flameorb' || itemid === 'toxicorb') && abilityid !== 'magicguard') {
|
|
if (itemid === 'toxicorb' && abilityid === 'poisonheal') {
|
|
physicalBulk *= 1 + 0.1 * (2 + moveCount['Stall']);
|
|
specialBulk *= 1 + 0.1 * (2 + moveCount['Stall']);
|
|
} else {
|
|
physicalBulk *= 0.8;
|
|
specialBulk *= 0.8;
|
|
}
|
|
}
|
|
if (itemid === 'lifeorb') {
|
|
physicalBulk *= 0.7;
|
|
specialBulk *= 0.7;
|
|
}
|
|
if (abilityid === 'multiscale' || abilityid === 'magicguard' || abilityid === 'regenerator') {
|
|
physicalBulk *= 1.4;
|
|
specialBulk *= 1.4;
|
|
}
|
|
if (itemid === 'eviolite') {
|
|
physicalBulk *= 1.5;
|
|
specialBulk *= 1.5;
|
|
}
|
|
if (itemid === 'assaultvest') {
|
|
specialBulk *= 1.5;
|
|
}
|
|
|
|
let bulk = physicalBulk + specialBulk;
|
|
if (bulk < 46000 && stats.spe >= 70) isFast = true;
|
|
if (hasMove['trickroom']) isFast = false;
|
|
moveCount['bulk'] = bulk;
|
|
moveCount['physicalBulk'] = physicalBulk;
|
|
moveCount['specialBulk'] = specialBulk;
|
|
|
|
if (
|
|
hasMove['agility'] || hasMove['dragondance'] || hasMove['quiverdance'] ||
|
|
hasMove['rockpolish'] || hasMove['shellsmash'] || hasMove['flamecharge']
|
|
) {
|
|
isFast = true;
|
|
} else if (abilityid === 'unburden' || abilityid === 'speedboost' || abilityid === 'motordrive') {
|
|
isFast = true;
|
|
moveCount['Ultrafast'] = 1;
|
|
} else if (abilityid === 'chlorophyll' || abilityid === 'swiftswim' || abilityid === 'sandrush') {
|
|
isFast = true;
|
|
moveCount['Ultrafast'] = 2;
|
|
} else if (itemid === 'salacberry') {
|
|
isFast = true;
|
|
}
|
|
const ultrafast = hasMove['agility'] || hasMove['shellsmash'] ||
|
|
hasMove['autotomize'] || hasMove['shiftgear'] || hasMove['rockpolish'];
|
|
if (ultrafast) {
|
|
moveCount['Ultrafast'] = 2;
|
|
}
|
|
moveCount['Fast'] = isFast ? 1 : 0;
|
|
|
|
this.moveCount = moveCount;
|
|
this.hasMove = hasMove;
|
|
|
|
if (template.id === 'ditto') return abilityid === 'imposter' ? 'Physically Defensive' : 'Fast Bulky Support';
|
|
if (template.id === 'shedinja') return 'Fast Physical Sweeper';
|
|
|
|
if (itemid === 'choiceband' && moveCount['PhysicalAttack'] >= 2) {
|
|
if (!isFast) return 'Bulky Band';
|
|
return 'Fast Band';
|
|
} else if (itemid === 'choicespecs' && moveCount['SpecialAttack'] >= 2) {
|
|
if (!isFast) return 'Bulky Specs';
|
|
return 'Fast Specs';
|
|
} else if (itemid === 'choicescarf') {
|
|
if (moveCount['PhysicalAttack'] === 0) return 'Special Scarf';
|
|
if (moveCount['SpecialAttack'] === 0) return 'Physical Scarf';
|
|
if (moveCount['PhysicalAttack'] > moveCount['SpecialAttack']) return 'Physical Biased Mixed Scarf';
|
|
if (moveCount['PhysicalAttack'] < moveCount['SpecialAttack']) return 'Special Biased Mixed Scarf';
|
|
if (stats.atk < stats.spa) return 'Special Biased Mixed Scarf';
|
|
return 'Physical Biased Mixed Scarf';
|
|
}
|
|
|
|
if (template.id === 'unown') return 'Fast Special Sweeper';
|
|
|
|
if (moveCount['PhysicalStall'] && moveCount['Restoration']) {
|
|
if (stats.spe > 110 && abilityid !== 'prankster') return 'Fast Bulky Support';
|
|
return 'Specially Defensive';
|
|
}
|
|
if (moveCount['SpecialStall'] && moveCount['Restoration'] && itemid !== 'lifeorb') {
|
|
if (stats.spe > 110 && abilityid !== 'prankster') return 'Fast Bulky Support';
|
|
return 'Physically Defensive';
|
|
}
|
|
|
|
let offenseBias: 'Physical' | 'Special' = 'Physical';
|
|
if (stats.spa > stats.atk && moveCount['Special'] > 1) offenseBias = 'Special';
|
|
else if (stats.atk > stats.spa && moveCount['Physical'] > 1) offenseBias = 'Physical';
|
|
else if (moveCount['Special'] > moveCount['Physical']) offenseBias = 'Special';
|
|
|
|
if (moveCount['Stall'] + moveCount['Support'] / 2 <= 2 && bulk < 135000 && moveCount[offenseBias] >= 1.5) {
|
|
if (isFast) {
|
|
if (bulk > 80000 && !moveCount['Ultrafast']) return 'Bulky ' + offenseBias + ' Sweeper';
|
|
return 'Fast ' + offenseBias + ' Sweeper';
|
|
} else {
|
|
if (moveCount[offenseBias] >= 3 || moveCount['Stall'] <= 0) {
|
|
return 'Bulky ' + offenseBias + ' Sweeper';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isFast && abilityid !== 'prankster') {
|
|
if (stats.spe > 100 || bulk < 55000 || moveCount['Ultrafast']) {
|
|
return 'Fast Bulky Support';
|
|
}
|
|
}
|
|
if (moveCount['SpecialStall']) return 'Physically Defensive';
|
|
if (moveCount['PhysicalStall']) return 'Specially Defensive';
|
|
if (template.id === 'blissey' || template.id === 'chansey') return 'Physically Defensive';
|
|
if (specialBulk >= physicalBulk) return 'Specially Defensive';
|
|
return 'Physically Defensive';
|
|
}
|
|
ensureMinEVs(evs: StatsTable, stat: StatName, min: number, evTotal: number) {
|
|
if (!evs[stat]) evs[stat] = 0;
|
|
let diff = min - evs[stat];
|
|
if (diff <= 0) return evTotal;
|
|
if (evTotal <= 504) {
|
|
let change = Math.min(508 - evTotal, diff);
|
|
evTotal += change;
|
|
evs[stat] += change;
|
|
diff -= change;
|
|
}
|
|
if (diff <= 0) return evTotal;
|
|
let evPriority = {def: 1, spd: 1, hp: 1, atk: 1, spa: 1, spe: 1};
|
|
let prioStat: StatName;
|
|
for (prioStat in evPriority) {
|
|
if (prioStat === stat) continue;
|
|
if (evs[prioStat] && evs[prioStat] > 128) {
|
|
evs[prioStat] -= diff;
|
|
evs[stat] += diff;
|
|
return evTotal;
|
|
}
|
|
}
|
|
return evTotal; // can't do it :(
|
|
}
|
|
ensureMaxEVs(evs: StatsTable, stat: StatName, min: number, evTotal: number) {
|
|
if (!evs[stat]) evs[stat] = 0;
|
|
let diff = evs[stat] - min;
|
|
if (diff <= 0) return evTotal;
|
|
evs[stat] -= diff;
|
|
evTotal -= diff;
|
|
return evTotal; // can't do it :(
|
|
}
|
|
guessEVs(set: PokemonSet, role: string): Partial<StatsTable> & {plusStat?: StatName | '', minusStat?: StatName | ''} {
|
|
if (!set) return {};
|
|
if (role === '?') return {};
|
|
let template = this.dex.getTemplate(set.species || set.name!);
|
|
let stats = template.baseStats;
|
|
|
|
let hasMove = this.hasMove;
|
|
let moveCount = this.moveCount;
|
|
|
|
let evs: StatsTable & {plusStat?: StatName | '', minusStat?: StatName | ''} = {
|
|
hp: 0, atk: 0, def: 0, spa: 0, spd: 0, spe: 0,
|
|
};
|
|
let plusStat: StatName | '' = '';
|
|
let minusStat: StatName | '' = '';
|
|
|
|
let statChart: {[role: string]: [StatName, StatName]} = {
|
|
'Bulky Band': ['atk', 'hp'],
|
|
'Fast Band': ['spe', 'atk'],
|
|
'Bulky Specs': ['spa', 'hp'],
|
|
'Fast Specs': ['spe', 'spa'],
|
|
'Physical Scarf': ['spe', 'atk'],
|
|
'Special Scarf': ['spe', 'spa'],
|
|
'Physical Biased Mixed Scarf': ['spe', 'atk'],
|
|
'Special Biased Mixed Scarf': ['spe', 'spa'],
|
|
'Fast Physical Sweeper': ['spe', 'atk'],
|
|
'Fast Special Sweeper': ['spe', 'spa'],
|
|
'Bulky Physical Sweeper': ['atk', 'hp'],
|
|
'Bulky Special Sweeper': ['spa', 'hp'],
|
|
'Fast Bulky Support': ['spe', 'hp'],
|
|
'Physically Defensive': ['def', 'hp'],
|
|
'Specially Defensive': ['spd', 'hp'],
|
|
};
|
|
|
|
plusStat = statChart[role][0];
|
|
if (role === 'Fast Bulky Support') moveCount['Ultrafast'] = 0;
|
|
if (plusStat === 'spe' && moveCount['Ultrafast']) {
|
|
if (statChart[role][1] === 'atk' || statChart[role][1] === 'spa') {
|
|
plusStat = statChart[role][1];
|
|
} else if (moveCount['Physical'] >= 3) {
|
|
plusStat = 'atk';
|
|
} else if (stats.spd > stats.def) {
|
|
plusStat = 'spd';
|
|
} else {
|
|
plusStat = 'def';
|
|
}
|
|
}
|
|
|
|
if (this.supportsAVs) {
|
|
// Let's Go, AVs enabled
|
|
evs = {hp: 200, atk: 200, def: 200, spa: 200, spd: 200, spe: 200};
|
|
if (!moveCount['PhysicalAttack']) evs.atk = 0;
|
|
if (!moveCount['SpecialAttack']) evs.spa = 0;
|
|
if (hasMove['gyroball'] || hasMove['trickroom']) evs.spe = 0;
|
|
} else if (!this.supportsEVs) {
|
|
// Let's Go, AVs disabled
|
|
// no change
|
|
} else if (this.ignoreEVLimits) {
|
|
// Gen 1-2, hackable EVs (like Hackmons)
|
|
evs = {hp: 252, atk: 252, def: 252, spa: 252, spd: 252, spe: 252};
|
|
if (!moveCount['PhysicalAttack']) evs.atk = 0;
|
|
if (!moveCount['SpecialAttack'] && this.dex.gen > 1) evs.spa = 0;
|
|
if (hasMove['gyroball'] || hasMove['trickroom']) evs.spe = 0;
|
|
if (this.dex.gen === 1) evs.spd = 0;
|
|
if (this.dex.gen < 3) return evs;
|
|
} else {
|
|
// Normal Gen 3-7
|
|
if (!statChart[role]) return {};
|
|
|
|
let evTotal = 0;
|
|
|
|
let primaryStat = statChart[role][0];
|
|
let stat = this.getStat(primaryStat, set, 252, plusStat === primaryStat ? 1.1 : 1.0);
|
|
let ev = 252;
|
|
while (ev > 0 && stat <= this.getStat(primaryStat, set, ev - 4, plusStat === primaryStat ? 1.1 : 1.0)) ev -= 4;
|
|
evs[primaryStat] = ev;
|
|
evTotal += ev;
|
|
|
|
let secondaryStat: StatName | null = statChart[role][1];
|
|
if (secondaryStat === 'hp' && set.level && set.level < 20) secondaryStat = 'spd';
|
|
stat = this.getStat(secondaryStat, set, 252, plusStat === secondaryStat ? 1.1 : 1.0);
|
|
ev = 252;
|
|
while (ev > 0 && stat <= this.getStat(secondaryStat, set, ev - 4, plusStat === secondaryStat ? 1.1 : 1.0)) ev -= 4;
|
|
evs[secondaryStat] = ev;
|
|
evTotal += ev;
|
|
|
|
let SRweaknesses = ['Fire', 'Flying', 'Bug', 'Ice'];
|
|
let SRresistances = ['Ground', 'Steel', 'Fighting'];
|
|
let SRweak = 0;
|
|
if (set.ability !== 'Magic Guard' && set.ability !== 'Mountaineer') {
|
|
if (SRweaknesses.indexOf(template.types[0]) >= 0) {
|
|
SRweak++;
|
|
} else if (SRresistances.indexOf(template.types[0]) >= 0) {
|
|
SRweak--;
|
|
}
|
|
if (SRweaknesses.indexOf(template.types[1]) >= 0) {
|
|
SRweak++;
|
|
} else if (SRresistances.indexOf(template.types[1]) >= 0) {
|
|
SRweak--;
|
|
}
|
|
}
|
|
let hpDivisibility = 0;
|
|
let hpShouldBeDivisible = false;
|
|
let hp = evs['hp'] || 0;
|
|
stat = this.getStat('hp', set, hp, 1);
|
|
if ((set.item === 'Leftovers' || set.item === 'Black Sludge') && hasMove['substitute'] && stat !== 404) {
|
|
hpDivisibility = 4;
|
|
} else if (set.item === 'Leftovers' || set.item === 'Black Sludge') {
|
|
hpDivisibility = 0;
|
|
} else if (hasMove['bellydrum'] && (set.item || '').slice(-5) === 'Berry') {
|
|
hpDivisibility = 2;
|
|
hpShouldBeDivisible = true;
|
|
} else if (hasMove['substitute'] && (set.item || '').slice(-5) === 'Berry') {
|
|
hpDivisibility = 4;
|
|
hpShouldBeDivisible = true;
|
|
} else if (SRweak >= 2 || hasMove['bellydrum']) {
|
|
hpDivisibility = 2;
|
|
} else if (SRweak >= 1 || hasMove['substitute'] || hasMove['transform']) {
|
|
hpDivisibility = 4;
|
|
} else if (set.ability !== 'Magic Guard') {
|
|
hpDivisibility = 8;
|
|
}
|
|
|
|
if (hpDivisibility) {
|
|
while (hp < 252 && evTotal < 508 && !(stat % hpDivisibility) !== hpShouldBeDivisible) {
|
|
hp += 4;
|
|
stat = this.getStat('hp', set, hp, 1);
|
|
evTotal += 4;
|
|
}
|
|
while (hp > 0 && !(stat % hpDivisibility) !== hpShouldBeDivisible) {
|
|
hp -= 4;
|
|
stat = this.getStat('hp', set, hp, 1);
|
|
evTotal -= 4;
|
|
}
|
|
while (hp > 0 && stat === this.getStat('hp', set, hp - 4, 1)) {
|
|
hp -= 4;
|
|
evTotal -= 4;
|
|
}
|
|
if (hp || evs['hp']) evs['hp'] = hp;
|
|
}
|
|
|
|
if (template.id === 'tentacruel') {
|
|
evTotal = this.ensureMinEVs(evs, 'spe', 16, evTotal);
|
|
} else if (template.id === 'skarmory') {
|
|
evTotal = this.ensureMinEVs(evs, 'spe', 24, evTotal);
|
|
} else if (template.id === 'jirachi') {
|
|
evTotal = this.ensureMinEVs(evs, 'spe', 32, evTotal);
|
|
} else if (template.id === 'celebi') {
|
|
evTotal = this.ensureMinEVs(evs, 'spe', 36, evTotal);
|
|
} else if (template.id === 'volcarona') {
|
|
evTotal = this.ensureMinEVs(evs, 'spe', 52, evTotal);
|
|
} else if (template.id === 'gliscor') {
|
|
evTotal = this.ensureMinEVs(evs, 'spe', 72, evTotal);
|
|
} else if (template.id === 'dragonite' && evs['hp']) {
|
|
evTotal = this.ensureMaxEVs(evs, 'spe', 220, evTotal);
|
|
}
|
|
|
|
if (evTotal < 508) {
|
|
let remaining = 508 - evTotal;
|
|
if (remaining > 252) remaining = 252;
|
|
secondaryStat = null;
|
|
if (!evs['atk'] && moveCount['PhysicalAttack'] >= 1) {
|
|
secondaryStat = 'atk';
|
|
} else if (!evs['spa'] && moveCount['SpecialAttack'] >= 1) {
|
|
secondaryStat = 'spa';
|
|
} else if (stats.hp === 1 && !evs['def']) {
|
|
secondaryStat = 'def';
|
|
} else if (stats.def === stats.spd && !evs['spd']) {
|
|
secondaryStat = 'spd';
|
|
} else if (!evs['spd']) {
|
|
secondaryStat = 'spd';
|
|
} else if (!evs['def']) {
|
|
secondaryStat = 'def';
|
|
}
|
|
if (secondaryStat) {
|
|
ev = remaining;
|
|
stat = this.getStat(secondaryStat, set, ev);
|
|
while (ev > 0 && stat === this.getStat(secondaryStat, set, ev - 4)) ev -= 4;
|
|
if (ev) evs[secondaryStat] = ev;
|
|
remaining -= ev;
|
|
}
|
|
if (remaining && !evs['spe']) {
|
|
ev = remaining;
|
|
stat = this.getStat('spe', set, ev);
|
|
while (ev > 0 && stat === this.getStat('spe', set, ev - 4)) ev -= 4;
|
|
if (ev) evs['spe'] = ev;
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
if (hasMove['gyroball'] || hasMove['trickroom']) {
|
|
minusStat = 'spe';
|
|
} else if (!moveCount['PhysicalAttack']) {
|
|
minusStat = 'atk';
|
|
} else if (moveCount['SpecialAttack'] < 1 && !evs['spa']) {
|
|
if (moveCount['SpecialAttack'] < moveCount['PhysicalAttack']) {
|
|
minusStat = 'spa';
|
|
} else if (!evs['atk']) {
|
|
minusStat = 'atk';
|
|
}
|
|
} else if (moveCount['PhysicalAttack'] < 1 && !evs['atk']) {
|
|
minusStat = 'atk';
|
|
} else if (stats.def > stats.spe && stats.spd > stats.spe && !evs['spe']) {
|
|
minusStat = 'spe';
|
|
} else if (stats.def > stats.spd) {
|
|
minusStat = 'spd';
|
|
} else {
|
|
minusStat = 'def';
|
|
}
|
|
|
|
if (plusStat === minusStat) {
|
|
minusStat = (plusStat === 'spe' ? 'spd' : 'spe');
|
|
}
|
|
|
|
evs.plusStat = plusStat;
|
|
evs.minusStat = minusStat;
|
|
|
|
return evs;
|
|
}
|
|
|
|
getStat(stat: StatName, set: PokemonSet, evOverride?: number, natureOverride?: number) {
|
|
let template = this.dex.getTemplate(set.species);
|
|
if (!template.exists) return 0;
|
|
|
|
let level = set.level || 100;
|
|
|
|
let baseStat = template.baseStats[stat];
|
|
|
|
let iv = (set.ivs && set.ivs[stat]);
|
|
if (typeof iv !== 'number') iv = 31;
|
|
if (this.dex.gen <= 2) iv &= 30;
|
|
|
|
let ev = (set.evs && set.evs[stat]);
|
|
if (typeof ev !== 'number') ev = (this.dex.gen > 2 ? 0 : 252);
|
|
if (evOverride !== undefined) ev = evOverride;
|
|
|
|
if (stat === 'hp') {
|
|
if (baseStat === 1) return 1;
|
|
if (!this.supportsEVs) return ~~(~~(2 * baseStat + iv + 100) * level / 100 + 10) + (this.supportsAVs ? ev : 0);
|
|
return ~~(~~(2 * baseStat + iv + ~~(ev / 4) + 100) * level / 100 + 10);
|
|
}
|
|
let val = ~~(~~(2 * baseStat + iv + ~~(ev / 4)) * level / 100 + 5);
|
|
if (!this.supportsEVs) {
|
|
val = ~~(~~(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 (!this.supportsEVs) {
|
|
let friendshipValue = ~~((70 / 255 / 10 + 1) * 100);
|
|
val = ~~(val) * friendshipValue / 100 + (this.supportsAVs ? ev : 0);
|
|
}
|
|
return ~~(val);
|
|
}
|
|
}
|
|
|
|
if (typeof require === 'function') {
|
|
// in Node
|
|
(global as any).BattleStatGuesser = BattleStatGuesser;
|
|
}
|