2023-04-27 10:38:19 +10:00
|
|
|
import {
|
2023-04-26 17:01:02 +10:00
|
|
|
calculate,
|
2023-04-27 11:17:22 +10:00
|
|
|
toID,
|
2023-04-26 17:01:02 +10:00
|
|
|
Field,
|
2023-04-27 10:38:19 +10:00
|
|
|
Generations,
|
2023-04-26 17:01:02 +10:00
|
|
|
Move,
|
|
|
|
Pokemon,
|
2023-04-27 10:38:19 +10:00
|
|
|
Result,
|
2023-04-27 09:45:47 +10:00
|
|
|
} from "@ajhyndman/smogon-calc";
|
2023-04-26 17:01:02 +10:00
|
|
|
import assert from "assert";
|
2023-04-27 10:38:19 +10:00
|
|
|
import { createToken, Lexer } from "chevrotain";
|
2023-04-26 17:01:02 +10:00
|
|
|
|
2023-04-27 09:51:37 +10:00
|
|
|
const gen = Generations.get(9);
|
|
|
|
|
2023-04-27 11:57:44 +10:00
|
|
|
/**
|
|
|
|
* @param {string} text
|
|
|
|
* @return {string}
|
|
|
|
*/
|
|
|
|
function escapeRegExp(text) {
|
|
|
|
return text.replace(/[\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
|
|
}
|
|
|
|
|
2023-04-26 17:01:02 +10:00
|
|
|
/**
|
|
|
|
* Creates a lexer.
|
|
|
|
*
|
|
|
|
* @returns {Lexer}
|
|
|
|
*/
|
|
|
|
function buildLexer() {
|
2023-04-27 10:38:19 +10:00
|
|
|
const Boost = createToken({ name: "Boost", pattern: /[+-]\d+/ });
|
|
|
|
const EV = createToken({ name: "EV", pattern: /\d+[+-]?/ });
|
|
|
|
const Stat = createToken({
|
2023-04-26 17:01:02 +10:00
|
|
|
name: "Stat",
|
|
|
|
pattern: /HP|Atk|Def|SpA|SpD|Spe/,
|
|
|
|
});
|
|
|
|
|
2023-04-27 10:38:19 +10:00
|
|
|
const Item = createToken({
|
2023-04-26 17:01:02 +10:00
|
|
|
name: "Item",
|
2023-04-27 11:57:44 +10:00
|
|
|
pattern: new RegExp(
|
|
|
|
[...gen.items].map((i) => escapeRegExp(i.name)).join("|")
|
|
|
|
),
|
2023-04-26 17:01:02 +10:00
|
|
|
});
|
2023-04-27 10:38:19 +10:00
|
|
|
const Ability = createToken({
|
2023-04-26 17:01:02 +10:00
|
|
|
name: "Ability",
|
2023-04-27 11:57:44 +10:00
|
|
|
pattern: new RegExp(
|
|
|
|
[...gen.abilities].map((a) => escapeRegExp(a.name)).join("|")
|
|
|
|
),
|
2023-04-26 17:01:02 +10:00
|
|
|
});
|
|
|
|
|
2023-04-27 10:38:19 +10:00
|
|
|
const Pokemon = createToken({
|
2023-04-26 17:01:02 +10:00
|
|
|
name: "Pokemon",
|
2023-04-27 11:57:44 +10:00
|
|
|
pattern: new RegExp(
|
2023-04-27 12:11:39 +10:00
|
|
|
[...gen.species]
|
|
|
|
.flatMap((s) => [
|
|
|
|
// Important: formes are expanded first because the parser takes the
|
|
|
|
// first match, and the original species is always a subset of a
|
|
|
|
// forme.
|
|
|
|
...(s.otherFormes || []).map(escapeRegExp),
|
|
|
|
escapeRegExp(s.name),
|
|
|
|
])
|
|
|
|
.join("|")
|
2023-04-27 11:57:44 +10:00
|
|
|
),
|
2023-04-26 17:01:02 +10:00
|
|
|
});
|
2023-04-27 10:38:19 +10:00
|
|
|
const Move = createToken({
|
2023-04-26 17:01:02 +10:00
|
|
|
name: "Move",
|
2023-04-27 11:57:44 +10:00
|
|
|
pattern: new RegExp(
|
2023-04-27 12:34:48 +10:00
|
|
|
[...gen.moves]
|
|
|
|
.map((m) => escapeRegExp(m.name))
|
|
|
|
.sort((a, b) => b.length - a.length)
|
|
|
|
.join("|")
|
2023-04-27 11:57:44 +10:00
|
|
|
),
|
2023-04-26 17:01:02 +10:00
|
|
|
});
|
|
|
|
|
2023-04-27 10:38:19 +10:00
|
|
|
const whitespace = createToken({
|
2023-04-26 17:01:02 +10:00
|
|
|
name: "Whitespace",
|
|
|
|
pattern: /\s+/,
|
|
|
|
group: Lexer.SKIPPED,
|
|
|
|
});
|
2023-04-27 10:38:19 +10:00
|
|
|
const div = createToken({
|
2023-04-26 17:01:02 +10:00
|
|
|
name: "div",
|
|
|
|
pattern: "/",
|
|
|
|
group: Lexer.SKIPPED,
|
|
|
|
});
|
2023-04-27 10:38:19 +10:00
|
|
|
const vs = createToken({
|
2023-04-26 17:01:02 +10:00
|
|
|
name: "vs",
|
2023-04-26 17:14:42 +10:00
|
|
|
pattern: /vs\.?/,
|
2023-04-26 17:01:02 +10:00
|
|
|
});
|
|
|
|
|
2023-04-27 11:58:24 +10:00
|
|
|
const teraEnter = createToken({
|
|
|
|
name: "teraEnter",
|
|
|
|
pattern: "Tera",
|
|
|
|
push_mode: "tera_mode",
|
|
|
|
});
|
|
|
|
const Tera = createToken({
|
|
|
|
name: "Tera",
|
|
|
|
pattern: new RegExp(
|
|
|
|
[...gen.types].map((t) => escapeRegExp(t.name)).join("|")
|
|
|
|
),
|
|
|
|
pop_mode: true,
|
|
|
|
});
|
|
|
|
|
2023-04-27 10:38:19 +10:00
|
|
|
const terrainEnter = createToken({
|
2023-04-26 17:01:02 +10:00
|
|
|
name: "terrainEnter",
|
|
|
|
pattern: "in",
|
|
|
|
push_mode: "terrain_mode",
|
|
|
|
});
|
2023-04-27 10:38:19 +10:00
|
|
|
const Weather = createToken({
|
2023-04-26 17:01:02 +10:00
|
|
|
name: "Weather",
|
|
|
|
pattern: /Sun|Rain|Sand|Snow/,
|
2023-04-27 11:58:02 +10:00
|
|
|
pop_mode: true,
|
2023-04-26 17:01:02 +10:00
|
|
|
});
|
2023-04-27 10:38:19 +10:00
|
|
|
const Terrain = createToken({
|
2023-04-26 17:01:02 +10:00
|
|
|
name: "Terrain",
|
|
|
|
pattern: /(Electric|Grassy|Misty|Psychic) Terrain/,
|
2023-04-27 11:58:02 +10:00
|
|
|
pop_mode: true,
|
2023-04-26 17:01:02 +10:00
|
|
|
});
|
|
|
|
|
2023-04-27 10:38:19 +10:00
|
|
|
const screenEnter = createToken({
|
2023-04-26 17:01:02 +10:00
|
|
|
name: "screenEnter",
|
|
|
|
pattern: "through",
|
|
|
|
push_mode: "screen_mode",
|
|
|
|
});
|
|
|
|
|
|
|
|
return new Lexer({
|
|
|
|
modes: {
|
|
|
|
default_mode: [
|
|
|
|
whitespace,
|
|
|
|
div,
|
|
|
|
vs,
|
|
|
|
Boost,
|
|
|
|
EV,
|
|
|
|
Stat,
|
|
|
|
Item,
|
|
|
|
Ability,
|
|
|
|
Pokemon,
|
|
|
|
Move,
|
2023-04-27 11:58:24 +10:00
|
|
|
teraEnter,
|
2023-04-26 17:01:02 +10:00
|
|
|
terrainEnter,
|
|
|
|
screenEnter,
|
|
|
|
],
|
2023-04-27 11:58:24 +10:00
|
|
|
tera_mode: [whitespace, Tera],
|
2023-04-27 11:58:02 +10:00
|
|
|
terrain_mode: [whitespace, Weather, Terrain],
|
2023-04-26 17:01:02 +10:00
|
|
|
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
|
2023-04-27 10:38:19 +10:00
|
|
|
* @param {import("chevrotain").IToken} token token
|
2023-04-26 17:01:02 +10:00
|
|
|
* @return {string} matched token
|
|
|
|
*/
|
|
|
|
function unwrapToken(type, token) {
|
|
|
|
assert(
|
|
|
|
token.tokenType.name == type,
|
2023-04-27 11:48:40 +10:00
|
|
|
`expected token ${type}, got ${token.tokenType.name}`
|
2023-04-26 17:01:02 +10:00
|
|
|
);
|
|
|
|
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
|
2023-04-27 10:38:19 +10:00
|
|
|
* @return {Result} calculation result
|
2023-04-26 17:01:02 +10:00
|
|
|
*/
|
|
|
|
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;
|
|
|
|
|
2023-04-27 10:38:19 +10:00
|
|
|
/** @type {import("@ajhyndman/smogon-calc").State.Pokemon} */
|
2023-04-26 17:01:02 +10:00
|
|
|
var attackerOpts = {};
|
2023-04-27 10:38:19 +10:00
|
|
|
/** @type {import("@ajhyndman/smogon-calc").State.Pokemon} */
|
2023-04-26 17:01:02 +10:00
|
|
|
var defenderOpts = {};
|
|
|
|
|
|
|
|
/** @type {string} */
|
|
|
|
var move;
|
2023-04-27 10:38:19 +10:00
|
|
|
/** @type {import("@ajhyndman/smogon-calc").State.Field} */
|
2023-04-26 17:01:02 +10:00
|
|
|
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 "Item":
|
|
|
|
opts().item = unwrapToken("Item", item);
|
|
|
|
break;
|
2023-04-27 11:24:17 +10:00
|
|
|
case "Ability":
|
|
|
|
opts().ability = unwrapToken("Ability", item);
|
|
|
|
break;
|
2023-04-26 17:01:02 +10:00
|
|
|
case "Pokemon":
|
|
|
|
{
|
|
|
|
let name = unwrapToken("Pokemon", item);
|
|
|
|
if (isAttacker) attacker = name;
|
|
|
|
else defender = name;
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
case "Move":
|
|
|
|
move = unwrapToken("Move", item);
|
|
|
|
break;
|
2023-04-27 11:58:24 +10:00
|
|
|
case "teraEnter":
|
|
|
|
opts().teraType = unwrapToken("Tera", it.next().value);
|
|
|
|
break;
|
2023-04-26 17:01:02 +10:00
|
|
|
case "terrainEnter":
|
|
|
|
{
|
|
|
|
let next = it.next().value;
|
|
|
|
switch (next.tokenType.name) {
|
|
|
|
case "Weather":
|
|
|
|
field.weather = next.image;
|
|
|
|
break;
|
|
|
|
case "Terrain":
|
2023-04-27 11:48:40 +10:00
|
|
|
field.terrain = next.image.replace(" Terrain", "");
|
2023-04-26 17:01:02 +10:00
|
|
|
break;
|
|
|
|
default:
|
|
|
|
throw Error(
|
2023-04-27 11:48:40 +10:00
|
|
|
`Unhandled field condition ${next.tokenType.name}: ${next.image}`
|
2023-04-26 17:01:02 +10:00
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-04-27 09:51:42 +10:00
|
|
|
// Pre-checking before the calculator throws unreadable errors.
|
2023-04-27 11:17:22 +10:00
|
|
|
if (!gen.species.get(toID(attacker)))
|
2023-04-27 10:38:19 +10:00
|
|
|
throw Error(`No species named ${attacker}`);
|
2023-04-27 11:17:22 +10:00
|
|
|
if (!gen.species.get(toID(defender)))
|
2023-04-27 10:38:19 +10:00
|
|
|
throw Error(`No species named ${defender}`);
|
2023-04-27 11:17:22 +10:00
|
|
|
if (!gen.moves.get(toID(move))) throw Error(`No move named ${move}`);
|
2023-04-27 09:51:42 +10:00
|
|
|
|
2023-04-26 17:01:02 +10:00
|
|
|
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";
|
2023-04-27 11:58:24 +10:00
|
|
|
var res = parseAndCalculate(text);
|
2023-04-26 17:01:02 +10:00
|
|
|
|
|
|
|
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"
|
|
|
|
);
|
2023-04-27 11:58:24 +10:00
|
|
|
|
|
|
|
res = parseAndCalculate("Tera Electric Iron Hands Wild Charge vs Basculin");
|
|
|
|
assert(res.attacker.teraType === "Electric", "should parse tera type");
|
2023-04-27 12:11:39 +10:00
|
|
|
|
|
|
|
res = parseAndCalculate("Gallade-Mega Triple Axel vs Gligar");
|
|
|
|
assert(res.attacker.name === "Gallade-Mega", "should parse Mega forme");
|
|
|
|
|
|
|
|
res = parseAndCalculate("Zoroark-Hisui Night Slash vs Golem");
|
|
|
|
assert(res.attacker.name === "Zoroark-Hisui", "should parse regional forme");
|
2023-04-27 12:34:48 +10:00
|
|
|
|
|
|
|
res = parseAndCalculate("Ditto Thunder Punch vs. Avalugg");
|
|
|
|
assert(
|
|
|
|
res.move.name === "Thunder Punch",
|
|
|
|
"should match entire move that is a superset of another move"
|
|
|
|
);
|
2023-04-26 17:01:02 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
export { parseAndCalculate, test };
|