Compare commits
17 Commits
6d92cb4deb
...
dont-resol
Author | SHA1 | Date | |
---|---|---|---|
d9d8151c74
|
|||
5f5603fff7
|
|||
ce70a9fb4d
|
|||
ea2285ed1d
|
|||
6c7d61f916
|
|||
2acbf9945c
|
|||
6aa1cac328
|
|||
0b163e8ffd
|
|||
01cf81f930
|
|||
4692ffa259
|
|||
53ece70381
|
|||
fa96d88964
|
|||
cf3906be94
|
|||
64cf863437
|
|||
c239aadfaf
|
|||
c75258a868
|
|||
70141b162f
|
13
README.md
13
README.md
@ -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
24
bot.py
@ -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
139
calc.js
@ -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 };
|
||||||
|
Reference in New Issue
Block a user