Compare commits
	
		
			19 Commits
		
	
	
		
			c6249b1b9b
			...
			dont-resol
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						d9d8151c74
	
				 | 
					
					
						|||
| 
						
						
							
						
						5f5603fff7
	
				 | 
					
					
						|||
| 
						
						
							
						
						ce70a9fb4d
	
				 | 
					
					
						|||
| 
						
						
							
						
						ea2285ed1d
	
				 | 
					
					
						|||
| 
						
						
							
						
						6c7d61f916
	
				 | 
					
					
						|||
| 
						
						
							
						
						2acbf9945c
	
				 | 
					
					
						|||
| 
						
						
							
						
						6aa1cac328
	
				 | 
					
					
						|||
| 
						
						
							
						
						0b163e8ffd
	
				 | 
					
					
						|||
| 
						
						
							
						
						01cf81f930
	
				 | 
					
					
						|||
| 
						
						
							
						
						4692ffa259
	
				 | 
					
					
						|||
| 
						
						
							
						
						53ece70381
	
				 | 
					
					
						|||
| 
						
						
							
						
						fa96d88964
	
				 | 
					
					
						|||
| 
						
						
							
						
						cf3906be94
	
				 | 
					
					
						|||
| 
						
						
							
						
						64cf863437
	
				 | 
					
					
						|||
| 
						
						
							
						
						c239aadfaf
	
				 | 
					
					
						|||
| 
						
						
							
						
						c75258a868
	
				 | 
					
					
						|||
| 
						
						
							
						
						70141b162f
	
				 | 
					
					
						|||
| 
						
						
							
						
						6d92cb4deb
	
				 | 
					
					
						|||
| 
						
						
							
						
						32e142b8cb
	
				 | 
					
					
						
							
								
								
									
										13
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								README.md
									
									
									
									
									
								
							@@ -3,6 +3,8 @@
 | 
			
		||||
Pokemon Showdown data processing, mostly for HHIRLLL's Pokemon league. Ugly as
 | 
			
		||||
fuck.
 | 
			
		||||
 | 
			
		||||
Also includes a bot to automate replay ingestion and provide misc utilities.
 | 
			
		||||
 | 
			
		||||
## Requirements
 | 
			
		||||
 | 
			
		||||
- 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
 | 
			
		||||
- calculate gametime based on active turns rather than moves used
 | 
			
		||||
  - 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("%")
 | 
			
		||||
 | 
			
		||||
    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]:
 | 
			
		||||
                try:
 | 
			
		||||
                    calc = await self._calculate(args)
 | 
			
		||||
                    await message.reply(content=calc.strip())
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    _log.exception("running calculation")
 | 
			
		||||
                    await message.reply(
 | 
			
		||||
                        content="```\n" + str(e) + "\n```", delete_after=30
 | 
			
		||||
                        content="```\n" + str(e) + "\n```", delete_after=10
 | 
			
		||||
                    )
 | 
			
		||||
            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:
 | 
			
		||||
        proc = sp.run(
 | 
			
		||||
            ["node", "calc_main.js", "--"] + args, stdout=sp.PIPE, stderr=sp.PIPE
 | 
			
		||||
        )
 | 
			
		||||
        stderr = proc.stderr.decode().strip()
 | 
			
		||||
        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()
 | 
			
		||||
 | 
			
		||||
    def is_replay(self, message: discord.Message) -> bool:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										141
									
								
								calc.js
									
									
									
									
									
								
							
							
						
						
									
										141
									
								
								calc.js
									
									
									
									
									
								
							@@ -1,12 +1,24 @@
 | 
			
		||||
import calc, {
 | 
			
		||||
import {
 | 
			
		||||
  calculate,
 | 
			
		||||
  Generations,
 | 
			
		||||
  toID,
 | 
			
		||||
  Field,
 | 
			
		||||
  Generations,
 | 
			
		||||
  Move,
 | 
			
		||||
  Pokemon,
 | 
			
		||||
  Result,
 | 
			
		||||
} from "@ajhyndman/smogon-calc";
 | 
			
		||||
import assert from "assert";
 | 
			
		||||
import chev, { Lexer } from "chevrotain";
 | 
			
		||||
import { createToken, Lexer } from "chevrotain";
 | 
			
		||||
 | 
			
		||||
const gen = Generations.get(9);
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @param {string} text
 | 
			
		||||
 * @return {string}
 | 
			
		||||
 */
 | 
			
		||||
function escapeRegExp(text) {
 | 
			
		||||
  return text.replace(/[\\^$*+?.()|[\]{}]/g, "\\$&");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Creates a lexer.
 | 
			
		||||
@@ -14,65 +26,95 @@ import chev, { Lexer } from "chevrotain";
 | 
			
		||||
 * @returns {Lexer}
 | 
			
		||||
 */
 | 
			
		||||
function buildLexer() {
 | 
			
		||||
  const Boost = chev.createToken({ name: "Boost", pattern: /[+-]\d+/ });
 | 
			
		||||
  const EV = chev.createToken({ name: "EV", pattern: /\d+[+-]?/ });
 | 
			
		||||
  const Stat = chev.createToken({
 | 
			
		||||
  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 = chev.createToken({
 | 
			
		||||
  const Item = createToken({
 | 
			
		||||
    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",
 | 
			
		||||
    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",
 | 
			
		||||
    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",
 | 
			
		||||
    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",
 | 
			
		||||
    pattern: /\s+/,
 | 
			
		||||
    group: Lexer.SKIPPED,
 | 
			
		||||
  });
 | 
			
		||||
  const div = chev.createToken({
 | 
			
		||||
  const div = createToken({
 | 
			
		||||
    name: "div",
 | 
			
		||||
    pattern: "/",
 | 
			
		||||
    group: Lexer.SKIPPED,
 | 
			
		||||
  });
 | 
			
		||||
  const vs = chev.createToken({
 | 
			
		||||
  const vs = createToken({
 | 
			
		||||
    name: "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",
 | 
			
		||||
    pattern: "in",
 | 
			
		||||
    push_mode: "terrain_mode",
 | 
			
		||||
  });
 | 
			
		||||
  const Weather = chev.createToken({
 | 
			
		||||
  const Weather = createToken({
 | 
			
		||||
    name: "Weather",
 | 
			
		||||
    pattern: /Sun|Rain|Sand|Snow/,
 | 
			
		||||
    pop_mode: true,
 | 
			
		||||
  });
 | 
			
		||||
  const Terrain = chev.createToken({
 | 
			
		||||
  const Terrain = createToken({
 | 
			
		||||
    name: "Terrain",
 | 
			
		||||
    pattern: /(Electric|Grassy|Misty|Psychic) Terrain/,
 | 
			
		||||
    pop_mode: true,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const screenEnter = chev.createToken({
 | 
			
		||||
  const screenEnter = createToken({
 | 
			
		||||
    name: "screenEnter",
 | 
			
		||||
    pattern: "through",
 | 
			
		||||
    push_mode: "screen_mode",
 | 
			
		||||
@@ -91,10 +133,12 @@ function buildLexer() {
 | 
			
		||||
        Ability,
 | 
			
		||||
        Pokemon,
 | 
			
		||||
        Move,
 | 
			
		||||
        teraEnter,
 | 
			
		||||
        terrainEnter,
 | 
			
		||||
        screenEnter,
 | 
			
		||||
      ],
 | 
			
		||||
      terrain_mode: [whitespace, screenEnter, Weather, Terrain],
 | 
			
		||||
      tera_mode: [whitespace, Tera],
 | 
			
		||||
      terrain_mode: [whitespace, Weather, Terrain],
 | 
			
		||||
      screen_mode: [whitespace, Move],
 | 
			
		||||
    },
 | 
			
		||||
    defaultMode: "default_mode",
 | 
			
		||||
@@ -115,15 +159,13 @@ function* iterate(arr) {
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @param {string} type token type
 | 
			
		||||
 * @param {chev.IToken} token token
 | 
			
		||||
 * @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
 | 
			
		||||
    `expected token ${type}, got ${token.tokenType.name}`
 | 
			
		||||
  );
 | 
			
		||||
  return token.image;
 | 
			
		||||
}
 | 
			
		||||
@@ -153,7 +195,7 @@ const NEG_NATURES = {
 | 
			
		||||
 * through Light Screen
 | 
			
		||||
 *
 | 
			
		||||
 * @param {string} line textual line
 | 
			
		||||
 * @return {calc.Result} calculation result
 | 
			
		||||
 * @return {Result} calculation result
 | 
			
		||||
 */
 | 
			
		||||
function parseAndCalculate(line) {
 | 
			
		||||
  const lexer = buildLexer();
 | 
			
		||||
@@ -167,14 +209,14 @@ function parseAndCalculate(line) {
 | 
			
		||||
  /** @type {string} */
 | 
			
		||||
  var defender;
 | 
			
		||||
 | 
			
		||||
  /** @type {calc.State.Pokemon} */
 | 
			
		||||
  /** @type {import("@ajhyndman/smogon-calc").State.Pokemon} */
 | 
			
		||||
  var attackerOpts = {};
 | 
			
		||||
  /** @type {calc.State.Pokemon} */
 | 
			
		||||
  /** @type {import("@ajhyndman/smogon-calc").State.Pokemon} */
 | 
			
		||||
  var defenderOpts = {};
 | 
			
		||||
 | 
			
		||||
  /** @type {string} */
 | 
			
		||||
  var move;
 | 
			
		||||
  /** @type {calc.State.Field} */
 | 
			
		||||
  /** @type {import("@ajhyndman/smogon-calc").State.Field} */
 | 
			
		||||
  var field = {};
 | 
			
		||||
 | 
			
		||||
  // Tokenising state.
 | 
			
		||||
@@ -213,11 +255,12 @@ function parseAndCalculate(line) {
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
        break;
 | 
			
		||||
      case "Stat":
 | 
			
		||||
        throw Error("Impossibel state: bare Stat");
 | 
			
		||||
      case "Item":
 | 
			
		||||
        opts().item = unwrapToken("Item", item);
 | 
			
		||||
        break;
 | 
			
		||||
      case "Ability":
 | 
			
		||||
        opts().ability = unwrapToken("Ability", item);
 | 
			
		||||
        break;
 | 
			
		||||
      case "Pokemon":
 | 
			
		||||
        {
 | 
			
		||||
          let name = unwrapToken("Pokemon", item);
 | 
			
		||||
@@ -228,6 +271,9 @@ function parseAndCalculate(line) {
 | 
			
		||||
      case "Move":
 | 
			
		||||
        move = unwrapToken("Move", item);
 | 
			
		||||
        break;
 | 
			
		||||
      case "teraEnter":
 | 
			
		||||
        opts().teraType = unwrapToken("Tera", it.next().value);
 | 
			
		||||
        break;
 | 
			
		||||
      case "terrainEnter":
 | 
			
		||||
        {
 | 
			
		||||
          let next = it.next().value;
 | 
			
		||||
@@ -236,13 +282,11 @@ function parseAndCalculate(line) {
 | 
			
		||||
              field.weather = next.image;
 | 
			
		||||
              break;
 | 
			
		||||
            case "Terrain":
 | 
			
		||||
              field.terrain = next.image;
 | 
			
		||||
              field.terrain = next.image.replace(" Terrain", "");
 | 
			
		||||
              break;
 | 
			
		||||
            default:
 | 
			
		||||
              throw Error(
 | 
			
		||||
                "Unhandled terrain %s: %s",
 | 
			
		||||
                next.tokenType.name,
 | 
			
		||||
                next.image
 | 
			
		||||
                `Unhandled field condition ${next.tokenType.name}: ${next.image}`
 | 
			
		||||
              );
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
@@ -266,7 +310,13 @@ function parseAndCalculate(line) {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const gen = Generations.get(8);
 | 
			
		||||
  // 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),
 | 
			
		||||
@@ -279,7 +329,7 @@ function parseAndCalculate(line) {
 | 
			
		||||
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);
 | 
			
		||||
  var res = parseAndCalculate(text);
 | 
			
		||||
 | 
			
		||||
  assert(res.attacker.boosts.spa === -2, "should have -2 SpA");
 | 
			
		||||
  assert(res.attacker.evs.spa === 8, "should have 8 SpA EVs");
 | 
			
		||||
@@ -294,6 +344,21 @@ function test() {
 | 
			
		||||
    res.desc().replace(/:.*/, "") === text,
 | 
			
		||||
    "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 };
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user