holy-heck-i-really-like-stats/calc.js

306 lines
7.6 KiB
JavaScript

import {
calculate,
toID,
Field,
Generations,
Move,
Pokemon,
Result,
} from "@ajhyndman/smogon-calc";
import assert from "assert";
import { createToken, Lexer } from "chevrotain";
const gen = Generations.get(9);
/**
* Creates a lexer.
*
* @returns {Lexer}
*/
function buildLexer() {
const Boost = createToken({ name: "Boost", pattern: /[+-]\d+/ });
const EV = createToken({ name: "EV", pattern: /\d+[+-]?/ });
const Stat = createToken({
name: "Stat",
pattern: /HP|Atk|Def|SpA|SpD|Spe/,
});
const Item = createToken({
name: "Item",
pattern: new RegExp([...gen.items].map((i) => i.name).join("|")),
});
const Ability = createToken({
name: "Ability",
pattern: new RegExp([...gen.abilities].map((a) => a.name).join("|")),
});
const Pokemon = createToken({
name: "Pokemon",
pattern: new RegExp([...gen.species].map((s) => s.name).join("|")),
});
const Move = createToken({
name: "Move",
pattern: new RegExp([...gen.moves].map((m) => m.name).join("|")),
});
const whitespace = createToken({
name: "Whitespace",
pattern: /\s+/,
group: Lexer.SKIPPED,
});
const div = createToken({
name: "div",
pattern: "/",
group: Lexer.SKIPPED,
});
const vs = createToken({
name: "vs",
pattern: /vs\.?/,
});
const terrainEnter = createToken({
name: "terrainEnter",
pattern: "in",
push_mode: "terrain_mode",
});
const Weather = createToken({
name: "Weather",
pattern: /Sun|Rain|Sand|Snow/,
});
const Terrain = createToken({
name: "Terrain",
pattern: /(Electric|Grassy|Misty|Psychic) Terrain/,
});
const screenEnter = 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 {import("chevrotain").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 {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 {import("@ajhyndman/smogon-calc").State.Pokemon} */
var attackerOpts = {};
/** @type {import("@ajhyndman/smogon-calc").State.Pokemon} */
var defenderOpts = {};
/** @type {string} */
var move;
/** @type {import("@ajhyndman/smogon-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("Impossible 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(toID(attacker)))
throw Error(`No species named ${attacker}`);
if (!gen.species.get(toID(defender)))
throw Error(`No species named ${defender}`);
if (!gen.moves.get(toID(move))) throw Error(`No move named ${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 };