holy-heck-i-really-like-stats/calc.js
2023-04-27 09:51:42 +10:00

306 lines
7.5 KiB
JavaScript

import calc, {
calculate,
Generations,
Field,
Move,
Pokemon,
} from "@ajhyndman/smogon-calc";
import assert from "assert";
import chev, { Lexer } from "chevrotain";
const gen = Generations.get(9);
/**
* Creates a lexer.
*
* @returns {Lexer}
*/
function buildLexer() {
const Boost = chev.createToken({ name: "Boost", pattern: /[+-]\d+/ });
const EV = chev.createToken({ name: "EV", pattern: /\d+[+-]?/ });
const Stat = chev.createToken({
name: "Stat",
pattern: /HP|Atk|Def|SpA|SpD|Spe/,
});
const Item = chev.createToken({
name: "Item",
pattern: new RegExp(calc.ITEMS.flatMap((gen) => gen).join("|")),
});
const Ability = chev.createToken({
name: "Ability",
pattern: new RegExp(calc.ABILITIES.flatMap((gen) => gen).join("|")),
});
const Pokemon = chev.createToken({
name: "Pokemon",
pattern: new RegExp(
calc.SPECIES.flatMap((gen) => Object.keys(gen)).join("|")
),
});
const Move = chev.createToken({
name: "Move",
pattern: new RegExp(
calc.MOVES.flatMap((gen) => Object.keys(gen)).join("|")
),
});
const whitespace = chev.createToken({
name: "Whitespace",
pattern: /\s+/,
group: Lexer.SKIPPED,
});
const div = chev.createToken({
name: "div",
pattern: "/",
group: Lexer.SKIPPED,
});
const vs = chev.createToken({
name: "vs",
pattern: /vs\.?/,
});
const terrainEnter = chev.createToken({
name: "terrainEnter",
pattern: "in",
push_mode: "terrain_mode",
});
const Weather = chev.createToken({
name: "Weather",
pattern: /Sun|Rain|Sand|Snow/,
});
const Terrain = chev.createToken({
name: "Terrain",
pattern: /(Electric|Grassy|Misty|Psychic) Terrain/,
});
const screenEnter = chev.createToken({
name: "screenEnter",
pattern: "through",
push_mode: "screen_mode",
});
return new Lexer({
modes: {
default_mode: [
whitespace,
div,
vs,
Boost,
EV,
Stat,
Item,
Ability,
Pokemon,
Move,
terrainEnter,
screenEnter,
],
terrain_mode: [whitespace, screenEnter, Weather, Terrain],
screen_mode: [whitespace, Move],
},
defaultMode: "default_mode",
});
}
/**
* @generator
* @template T item type
* @param {T[]} arr
* @yields {T} item
*/
function* iterate(arr) {
for (const a of arr) {
yield a;
}
}
/**
* @param {string} type token type
* @param {chev.IToken} token token
* @return {string} matched token
*/
function unwrapToken(type, token) {
assert(
token.tokenType.name == type,
"expected token %s, got %s",
type,
token.tokenType.name
);
return token.image;
}
const POS_NATURES = {
atk: "Adamant",
def: "Bold",
spa: "Modest",
spd: "Calm",
spe: "Jolly",
};
const NEG_NATURES = {
atk: "Modest",
def: "Lonely",
spa: "Adamant",
spd: "Rash",
spe: "Brave",
};
/**
* Parses a Smogon calculator output:
*
* -2 8 SpA Choice Specs Torkoal vs.
* 252 HP / 4+ SpD Assault Vest Abomasnow
* in Sun
* through Light Screen
*
* @param {string} line textual line
* @return {calc.Result} calculation result
*/
function parseAndCalculate(line) {
const lexer = buildLexer();
const result = lexer.tokenize(line);
if (result.errors && result.errors.length > 0) {
console.error("Unparsed tokens: %o", result.errors);
}
/** @type {string} */
var attacker;
/** @type {string} */
var defender;
/** @type {calc.State.Pokemon} */
var attackerOpts = {};
/** @type {calc.State.Pokemon} */
var defenderOpts = {};
/** @type {string} */
var move;
/** @type {calc.State.Field} */
var field = {};
// Tokenising state.
var isAttacker = true;
var it = iterate(result.tokens);
const opts = () => (isAttacker ? attackerOpts : defenderOpts);
while (true) {
let item = it.next().value;
if (!item) break;
switch (item.tokenType.name) {
case "Boost":
{
let boost = unwrapToken("Boost", item);
let ev = unwrapToken("EV", it.next().value);
let stat = unwrapToken("Stat", it.next().value).toLowerCase();
opts().boosts = { [stat]: parseInt(boost), ...opts().boosts };
opts().evs = { [stat]: parseInt(ev), ...opts().evs };
if (ev.endsWith("+")) {
opts().nature = POS_NATURES[stat];
} else if (ev.endsWith("-")) {
opts().nature = NEG_NATURES[stat];
}
}
break;
case "EV":
{
let ev = unwrapToken("EV", item);
let stat = unwrapToken("Stat", it.next().value).toLowerCase();
opts().evs = { [stat]: parseInt(ev), ...opts().evs };
if (ev.endsWith("+")) {
opts().nature = POS_NATURES[stat];
} else if (ev.endsWith("-")) {
opts().nature = NEG_NATURES[stat];
}
}
break;
case "Stat":
throw Error("Impossibel state: bare Stat");
case "Item":
opts().item = unwrapToken("Item", item);
break;
case "Pokemon":
{
let name = unwrapToken("Pokemon", item);
if (isAttacker) attacker = name;
else defender = name;
}
break;
case "Move":
move = unwrapToken("Move", item);
break;
case "terrainEnter":
{
let next = it.next().value;
switch (next.tokenType.name) {
case "Weather":
field.weather = next.image;
break;
case "Terrain":
field.terrain = next.image;
break;
default:
throw Error(
"Unhandled terrain %s: %s",
next.tokenType.name,
next.image
);
}
}
break;
case "screenEnter":
{
let screen = unwrapToken("Move", it.next().value);
field.defenderSide = field.defenderSide || {};
field.defenderSide.isLightScreen = screen === "Light Screen";
field.defenderSide.isReflect = screen === "Reflect";
field.defenderSide.isAuroraVeil = screen === "Aurora Veil";
field.defenderSide.is;
}
break;
case "vs":
isAttacker = false;
break;
default:
console.error("unmatched token type: %s", item.tokenType.name);
break;
}
}
// Pre-checking before the calculator throws unreadable errors.
if (!gen.species.get(attacker)) throw Error(`No species ${attacker}`);
if (!gen.species.get(defender)) throw Error(`No species ${attacker}`);
if (!gen.moves.get(move)) throw Error(`No move ${move}`);
return calculate(
gen,
new Pokemon(gen, attacker, attackerOpts),
new Pokemon(gen, defender, defenderOpts),
new Move(gen, move),
new Field(field)
);
}
function test() {
const text =
"-2 8 SpA Choice Specs Torkoal Overheat vs. 252 HP / 4+ SpD Assault Vest Abomasnow in Sun through Light Screen";
const res = parseAndCalculate(text);
assert(res.attacker.boosts.spa === -2, "should have -2 SpA");
assert(res.attacker.evs.spa === 8, "should have 8 SpA EVs");
assert(res.attacker.item === "Choice Specs", "should have Choice Specs");
assert(res.attacker.name === "Torkoal", "should be Torkoal");
assert(res.move.name === "Overheat", "should be Overheat");
assert(res.defender.evs.hp === 252, "should have 252 HP EVs");
assert(res.defender.evs.spd === 4, "should have 4 SpD EVs");
assert(res.field.weather === "Sun", "should be in sun");
assert(
res.desc().replace(/:.*/, "") === text,
"non-damage text should be equivalent to input"
);
}
export { parseAndCalculate, test };