Compare commits

..

17 Commits

Author SHA1 Message Date
d9d8151c74 add more future work for s4? 2023-05-21 22:20:11 +10:00
5f5603fff7 fix parsing of Thunder / Thunder Punch 2023-04-27 12:34:48 +10:00
ce70a9fb4d capture stderr on good calculations 2023-04-27 12:30:48 +10:00
ea2285ed1d update help 2023-04-27 12:16:09 +10:00
6c7d61f916 fix support for mega/formes 2023-04-27 12:11:39 +10:00
2acbf9945c support tera 2023-04-27 12:11:36 +10:00
6aa1cac328 disallow "in through <screen>" 2023-04-27 11:58:02 +10:00
0b163e8ffd escape regex tokens 2023-04-27 11:57:44 +10:00
01cf81f930 fix terrain passing 2023-04-27 11:48:40 +10:00
4692ffa259 fix calculator help 2023-04-27 11:41:56 +10:00
53ece70381 add calculator help 2023-04-27 11:31:54 +10:00
fa96d88964 auto-delete errors faster 2023-04-27 11:26:03 +10:00
cf3906be94 support abilities in calc 2023-04-27 11:24:17 +10:00
64cf863437 fix logging 2023-04-27 11:23:48 +10:00
c239aadfaf fix ID lookup for pre-validation 2023-04-27 11:17:31 +10:00
c75258a868 update readme 2023-04-27 10:41:28 +10:00
70141b162f fix typing and generation-based matching 2023-04-27 10:38:19 +10:00
3 changed files with 132 additions and 44 deletions

View File

@ -3,6 +3,8 @@
Pokemon Showdown data processing, mostly for HHIRLLL's Pokemon league. Ugly as Pokemon Showdown data processing, mostly for HHIRLLL's Pokemon league. Ugly as
fuck. fuck.
Also includes a bot to automate replay ingestion and provide misc utilities.
## Requirements ## Requirements
- Python with sqlite - Python with sqlite
@ -40,3 +42,14 @@ whatever SQL queries against the data file (default `data.db`) you want.
- include timestamps in logs to correlate KOs with the mon that KOed - include timestamps in logs to correlate KOs with the mon that KOed
- calculate gametime based on active turns rather than moves used - calculate gametime based on active turns rather than moves used
- also solves the issue where paralyzed/confused turns are not counted - also solves the issue where paralyzed/confused turns are not counted
- use asyncio in bot
- allow for entering unplayed forfeits
- easy correction of game drift (e.g., two games played for a single team in a week)
- collect stats on teras and megas
- inline common queries into the base database as views
- channel filtering for replay collection
- move/damage tracking:
- delayed damage (Future Sight and co)
- self-damage misattribution (Belly Drum, possibly Explosion)
- indirect status (Toxic Spikes)
- weather?

24
bot.py
View File

@ -72,24 +72,40 @@ class BotClient(discord.Client):
return message.content.startswith("%") return message.content.startswith("%")
async def on_command(self, message: discord.Message): async def on_command(self, message: discord.Message):
match message.content.split(" "): match re.split(" +", message.content):
case ["%calc?"] | ["%calc", "?"]:
await message.reply(content=self._help_calculate())
case ["%calc", *args]: case ["%calc", *args]:
try: try:
calc = await self._calculate(args) calc = await self._calculate(args)
await message.reply(content=calc.strip()) await message.reply(content=calc.strip())
except Exception as e: except Exception as e:
_log.exception("running calculation")
await message.reply( await message.reply(
content="```\n" + str(e) + "\n```", delete_after=30 content="```\n" + str(e) + "\n```", delete_after=10
) )
case _: case _:
_log.info(f"Unrecognised command {command}") _log.info(f"Unrecognised command {message.content}")
await message.reply(content="Unrecognised command.")
def _help_calculate(self) -> str:
return (
"Run Showdown damage calculations.\n"
"Example format:\n"
"` %calc -2 8 SpA Choice Specs Torkoal Overheat vs. 252 HP / 4+ SpD Assault Vest Abomasnow in Sun through Light Screen`\n"
"Supported: attacker/defender boosts, EVs, tera, item, ability, species; weather/terrain; screens."
"Not supported (popular): non-default multi hits; hazards."
)
async def _calculate(self, args: list[str]) -> str: async def _calculate(self, args: list[str]) -> str:
proc = sp.run( proc = sp.run(
["node", "calc_main.js", "--"] + args, stdout=sp.PIPE, stderr=sp.PIPE ["node", "calc_main.js", "--"] + args, stdout=sp.PIPE, stderr=sp.PIPE
) )
stderr = proc.stderr.decode().strip()
if proc.returncode != 0: if proc.returncode != 0:
raise Exception(proc.stderr.decode()) raise Exception(stderr)
if stderr:
_log.warning(f"running calculation '{args}': {stderr}")
return proc.stdout.decode() return proc.stdout.decode()
def is_replay(self, message: discord.Message) -> bool: def is_replay(self, message: discord.Message) -> bool:

139
calc.js
View File

@ -1,80 +1,120 @@
import calc, { import {
calculate, calculate,
Generations, toID,
Field, Field,
Generations,
Move, Move,
Pokemon, Pokemon,
Result,
} from "@ajhyndman/smogon-calc"; } from "@ajhyndman/smogon-calc";
import assert from "assert"; import assert from "assert";
import chev, { Lexer } from "chevrotain"; import { createToken, Lexer } from "chevrotain";
const gen = Generations.get(9); const gen = Generations.get(9);
/**
* @param {string} text
* @return {string}
*/
function escapeRegExp(text) {
return text.replace(/[\\^$*+?.()|[\]{}]/g, "\\$&");
}
/** /**
* Creates a lexer. * Creates a lexer.
* *
* @returns {Lexer} * @returns {Lexer}
*/ */
function buildLexer() { function buildLexer() {
const Boost = chev.createToken({ name: "Boost", pattern: /[+-]\d+/ }); const Boost = createToken({ name: "Boost", pattern: /[+-]\d+/ });
const EV = chev.createToken({ name: "EV", pattern: /\d+[+-]?/ }); const EV = createToken({ name: "EV", pattern: /\d+[+-]?/ });
const Stat = chev.createToken({ const Stat = createToken({
name: "Stat", name: "Stat",
pattern: /HP|Atk|Def|SpA|SpD|Spe/, pattern: /HP|Atk|Def|SpA|SpD|Spe/,
}); });
const Item = chev.createToken({ const Item = createToken({
name: "Item", name: "Item",
pattern: new RegExp(calc.ITEMS.flatMap((gen) => gen).join("|")), pattern: new RegExp(
[...gen.items].map((i) => escapeRegExp(i.name)).join("|")
),
}); });
const Ability = chev.createToken({ const Ability = createToken({
name: "Ability", name: "Ability",
pattern: new RegExp(calc.ABILITIES.flatMap((gen) => gen).join("|")), pattern: new RegExp(
[...gen.abilities].map((a) => escapeRegExp(a.name)).join("|")
),
}); });
const Pokemon = chev.createToken({ const Pokemon = createToken({
name: "Pokemon", name: "Pokemon",
pattern: new RegExp( pattern: new RegExp(
calc.SPECIES.flatMap((gen) => Object.keys(gen)).join("|") [...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("|")
), ),
}); });
const Move = chev.createToken({ const Move = createToken({
name: "Move", name: "Move",
pattern: new RegExp( pattern: new RegExp(
calc.MOVES.flatMap((gen) => Object.keys(gen)).join("|") [...gen.moves]
.map((m) => escapeRegExp(m.name))
.sort((a, b) => b.length - a.length)
.join("|")
), ),
}); });
const whitespace = chev.createToken({ const whitespace = createToken({
name: "Whitespace", name: "Whitespace",
pattern: /\s+/, pattern: /\s+/,
group: Lexer.SKIPPED, group: Lexer.SKIPPED,
}); });
const div = chev.createToken({ const div = createToken({
name: "div", name: "div",
pattern: "/", pattern: "/",
group: Lexer.SKIPPED, group: Lexer.SKIPPED,
}); });
const vs = chev.createToken({ const vs = createToken({
name: "vs", name: "vs",
pattern: /vs\.?/, pattern: /vs\.?/,
}); });
const terrainEnter = chev.createToken({ 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,
});
const terrainEnter = createToken({
name: "terrainEnter", name: "terrainEnter",
pattern: "in", pattern: "in",
push_mode: "terrain_mode", push_mode: "terrain_mode",
}); });
const Weather = chev.createToken({ const Weather = createToken({
name: "Weather", name: "Weather",
pattern: /Sun|Rain|Sand|Snow/, pattern: /Sun|Rain|Sand|Snow/,
pop_mode: true,
}); });
const Terrain = chev.createToken({ const Terrain = createToken({
name: "Terrain", name: "Terrain",
pattern: /(Electric|Grassy|Misty|Psychic) Terrain/, pattern: /(Electric|Grassy|Misty|Psychic) Terrain/,
pop_mode: true,
}); });
const screenEnter = chev.createToken({ const screenEnter = createToken({
name: "screenEnter", name: "screenEnter",
pattern: "through", pattern: "through",
push_mode: "screen_mode", push_mode: "screen_mode",
@ -93,10 +133,12 @@ function buildLexer() {
Ability, Ability,
Pokemon, Pokemon,
Move, Move,
teraEnter,
terrainEnter, terrainEnter,
screenEnter, screenEnter,
], ],
terrain_mode: [whitespace, screenEnter, Weather, Terrain], tera_mode: [whitespace, Tera],
terrain_mode: [whitespace, Weather, Terrain],
screen_mode: [whitespace, Move], screen_mode: [whitespace, Move],
}, },
defaultMode: "default_mode", defaultMode: "default_mode",
@ -117,15 +159,13 @@ function* iterate(arr) {
/** /**
* @param {string} type token type * @param {string} type token type
* @param {chev.IToken} token token * @param {import("chevrotain").IToken} token token
* @return {string} matched token * @return {string} matched token
*/ */
function unwrapToken(type, token) { function unwrapToken(type, token) {
assert( assert(
token.tokenType.name == type, token.tokenType.name == type,
"expected token %s, got %s", `expected token ${type}, got ${token.tokenType.name}`
type,
token.tokenType.name
); );
return token.image; return token.image;
} }
@ -155,7 +195,7 @@ const NEG_NATURES = {
* through Light Screen * through Light Screen
* *
* @param {string} line textual line * @param {string} line textual line
* @return {calc.Result} calculation result * @return {Result} calculation result
*/ */
function parseAndCalculate(line) { function parseAndCalculate(line) {
const lexer = buildLexer(); const lexer = buildLexer();
@ -169,14 +209,14 @@ function parseAndCalculate(line) {
/** @type {string} */ /** @type {string} */
var defender; var defender;
/** @type {calc.State.Pokemon} */ /** @type {import("@ajhyndman/smogon-calc").State.Pokemon} */
var attackerOpts = {}; var attackerOpts = {};
/** @type {calc.State.Pokemon} */ /** @type {import("@ajhyndman/smogon-calc").State.Pokemon} */
var defenderOpts = {}; var defenderOpts = {};
/** @type {string} */ /** @type {string} */
var move; var move;
/** @type {calc.State.Field} */ /** @type {import("@ajhyndman/smogon-calc").State.Field} */
var field = {}; var field = {};
// Tokenising state. // Tokenising state.
@ -215,11 +255,12 @@ function parseAndCalculate(line) {
} }
} }
break; break;
case "Stat":
throw Error("Impossibel state: bare Stat");
case "Item": case "Item":
opts().item = unwrapToken("Item", item); opts().item = unwrapToken("Item", item);
break; break;
case "Ability":
opts().ability = unwrapToken("Ability", item);
break;
case "Pokemon": case "Pokemon":
{ {
let name = unwrapToken("Pokemon", item); let name = unwrapToken("Pokemon", item);
@ -230,6 +271,9 @@ function parseAndCalculate(line) {
case "Move": case "Move":
move = unwrapToken("Move", item); move = unwrapToken("Move", item);
break; break;
case "teraEnter":
opts().teraType = unwrapToken("Tera", it.next().value);
break;
case "terrainEnter": case "terrainEnter":
{ {
let next = it.next().value; let next = it.next().value;
@ -238,13 +282,11 @@ function parseAndCalculate(line) {
field.weather = next.image; field.weather = next.image;
break; break;
case "Terrain": case "Terrain":
field.terrain = next.image; field.terrain = next.image.replace(" Terrain", "");
break; break;
default: default:
throw Error( throw Error(
"Unhandled terrain %s: %s", `Unhandled field condition ${next.tokenType.name}: ${next.image}`
next.tokenType.name,
next.image
); );
} }
} }
@ -269,9 +311,11 @@ function parseAndCalculate(line) {
} }
// Pre-checking before the calculator throws unreadable errors. // Pre-checking before the calculator throws unreadable errors.
if (!gen.species.get(attacker)) throw Error(`No species ${attacker}`); if (!gen.species.get(toID(attacker)))
if (!gen.species.get(defender)) throw Error(`No species ${attacker}`); throw Error(`No species named ${attacker}`);
if (!gen.moves.get(move)) throw Error(`No move ${move}`); 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( return calculate(
gen, gen,
@ -285,7 +329,7 @@ function parseAndCalculate(line) {
function test() { function test() {
const text = const text =
"-2 8 SpA Choice Specs Torkoal Overheat vs. 252 HP / 4+ SpD Assault Vest Abomasnow in Sun through Light Screen"; "-2 8 SpA Choice Specs Torkoal Overheat vs. 252 HP / 4+ SpD Assault Vest Abomasnow in Sun through Light Screen";
const res = parseAndCalculate(text); var res = parseAndCalculate(text);
assert(res.attacker.boosts.spa === -2, "should have -2 SpA"); assert(res.attacker.boosts.spa === -2, "should have -2 SpA");
assert(res.attacker.evs.spa === 8, "should have 8 SpA EVs"); assert(res.attacker.evs.spa === 8, "should have 8 SpA EVs");
@ -300,6 +344,21 @@ function test() {
res.desc().replace(/:.*/, "") === text, res.desc().replace(/:.*/, "") === text,
"non-damage text should be equivalent to input" "non-damage text should be equivalent to input"
); );
res = parseAndCalculate("Tera Electric Iron Hands Wild Charge vs Basculin");
assert(res.attacker.teraType === "Electric", "should parse tera type");
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");
res = parseAndCalculate("Ditto Thunder Punch vs. Avalugg");
assert(
res.move.name === "Thunder Punch",
"should match entire move that is a superset of another move"
);
} }
export { parseAndCalculate, test }; export { parseAndCalculate, test };