Compare commits
50 Commits
trunk
...
dont-resol
Author | SHA1 | Date | |
---|---|---|---|
d9d8151c74 | |||
5f5603fff7 | |||
ce70a9fb4d | |||
ea2285ed1d | |||
6c7d61f916 | |||
2acbf9945c | |||
6aa1cac328 | |||
0b163e8ffd | |||
01cf81f930 | |||
4692ffa259 | |||
53ece70381 | |||
fa96d88964 | |||
cf3906be94 | |||
64cf863437 | |||
c239aadfaf | |||
c75258a868 | |||
70141b162f | |||
6d92cb4deb | |||
32e142b8cb | |||
c6249b1b9b | |||
4f1e84ba8e | |||
225cac02c0 | |||
a1f2f175cd | |||
4b07aeb10c | |||
699a833285 | |||
648deb83db | |||
a926461f8f | |||
a65137307b | |||
0afca583b0 | |||
dc668c67a0 | |||
bf1793e561 | |||
d6c249199d | |||
0d46b82beb | |||
dab02cfe18 | |||
6126db5fe0 | |||
e9b6ec4b5d | |||
25a347cd88 | |||
3b9f9d58a6 | |||
55d227d05d | |||
4f7a9b642e | |||
57f249d028 | |||
5d67b7aaa8 | |||
49407f0c45 | |||
fd4ac1d544 | |||
73a31d7b99 | |||
a8730ed579 | |||
dfc1093b39 | |||
29df16ecd3 | |||
6b36720c37 | |||
39f412c0fe |
145
.gitignore
vendored
145
.gitignore
vendored
@ -11,3 +11,148 @@ htmlcov
|
||||
*.db
|
||||
cache/
|
||||
token
|
||||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/node
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
### Node Patch ###
|
||||
# Serverless Webpack directories
|
||||
.webpack/
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
# SvelteKit build / generate output
|
||||
.svelte-kit
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/node
|
||||
|
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?
|
||||
|
119
bot.py
119
bot.py
@ -1,18 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from collections import deque
|
||||
from discord.utils import setup_logging
|
||||
from typing import Optional
|
||||
import argparse
|
||||
import discord
|
||||
import logging
|
||||
import os.path
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess as sp
|
||||
|
||||
discord.utils.setup_logging()
|
||||
_log = logging.getLogger("statbot")
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_GAMES = "games.txt"
|
||||
_DB = "holy-heck2.db"
|
||||
_DB = "holy-heck.db"
|
||||
_DB_DEST = f"/var/lib/grafana/{_DB}"
|
||||
|
||||
|
||||
@ -39,15 +43,102 @@ def _update_db():
|
||||
|
||||
|
||||
class BotClient(discord.Client):
|
||||
def __init__(
|
||||
self,
|
||||
intents: discord.Intents,
|
||||
leaguefacts: Optional[list[str]] = None,
|
||||
):
|
||||
super().__init__(intents=intents)
|
||||
self._recentfacts = deque(maxlen=min(len(leaguefacts) - 1, 5))
|
||||
self._leaguefacts = leaguefacts or []
|
||||
self._leaguefacts.append(
|
||||
f"I know {len(self._leaguefacts)} leaguefacts but you'll never see most of them."
|
||||
)
|
||||
random.seed()
|
||||
|
||||
async def on_ready(self):
|
||||
_log.info(f"ready as {self.user}")
|
||||
|
||||
async def on_message(self, message: discord.Message):
|
||||
content = message.content
|
||||
if re.match("https://replay.pokemonshowdown.com/dl-.*", content):
|
||||
_log.info(f"Recognised {content} as a League game")
|
||||
_write_game(content)
|
||||
_update_db()
|
||||
if self.is_command(message):
|
||||
await self.on_command(message)
|
||||
elif self.is_replay(message):
|
||||
await self.on_replay(message)
|
||||
elif self.is_leaguefact(message):
|
||||
await self.on_leaguefact(message)
|
||||
|
||||
def is_command(self, message: discord.Message) -> bool:
|
||||
return message.content.startswith("%")
|
||||
|
||||
async def on_command(self, message: discord.Message):
|
||||
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=10
|
||||
)
|
||||
case _:
|
||||
_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(stderr)
|
||||
if stderr:
|
||||
_log.warning(f"running calculation '{args}': {stderr}")
|
||||
return proc.stdout.decode()
|
||||
|
||||
def is_replay(self, message: discord.Message) -> bool:
|
||||
if re.match("https://replay.pokemonshowdown.com/dl-.*", message.content):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def on_replay(self, message: discord.Message):
|
||||
_log.info(f"Recognised {message.content} as a League game")
|
||||
_write_game(message.content)
|
||||
_update_db()
|
||||
|
||||
def is_leaguefact(self, message: discord.Message) -> bool:
|
||||
return message.content.lower() in ["leaguefact", "leaguefacts"]
|
||||
|
||||
async def on_leaguefact(self, message: discord.Message):
|
||||
_log.info("leaguefact requested")
|
||||
fact = self._select_leaguefact()
|
||||
if fact:
|
||||
await message.channel.send(f"Did you know? {fact}")
|
||||
else:
|
||||
await message.channel.send("There are no league facts.")
|
||||
|
||||
def _select_leaguefact(self) -> Optional[str]:
|
||||
if not self._leaguefacts:
|
||||
return None
|
||||
choice = None
|
||||
while True:
|
||||
choice = random.choice(self._leaguefacts)
|
||||
if choice not in self._recentfacts:
|
||||
break
|
||||
self._recentfacts.append(choice)
|
||||
return choice
|
||||
|
||||
|
||||
def main():
|
||||
@ -59,12 +150,26 @@ def main():
|
||||
default="token",
|
||||
help="file containing Discord API token",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--facts",
|
||||
metavar="FILE",
|
||||
default="facts.txt",
|
||||
help="file containing leagefacts",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
facts = []
|
||||
if os.path.exists(args.facts):
|
||||
with open(args.facts) as f:
|
||||
for line in f:
|
||||
facts.append(line)
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
client = BotClient(intents=intents)
|
||||
client = BotClient(leaguefacts=facts, intents=intents)
|
||||
|
||||
with open(args.token_file) as f:
|
||||
token = f.read().strip()
|
||||
client.run(token, log_handler=None)
|
||||
|
364
calc.js
Normal file
364
calc.js
Normal file
@ -0,0 +1,364 @@
|
||||
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);
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @return {string}
|
||||
*/
|
||||
function escapeRegExp(text) {
|
||||
return text.replace(/[\\^$*+?.()|[\]{}]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => escapeRegExp(i.name)).join("|")
|
||||
),
|
||||
});
|
||||
const Ability = createToken({
|
||||
name: "Ability",
|
||||
pattern: new RegExp(
|
||||
[...gen.abilities].map((a) => escapeRegExp(a.name)).join("|")
|
||||
),
|
||||
});
|
||||
|
||||
const Pokemon = createToken({
|
||||
name: "Pokemon",
|
||||
pattern: new RegExp(
|
||||
[...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 = createToken({
|
||||
name: "Move",
|
||||
pattern: new RegExp(
|
||||
[...gen.moves]
|
||||
.map((m) => escapeRegExp(m.name))
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.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 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 = createToken({
|
||||
name: "Weather",
|
||||
pattern: /Sun|Rain|Sand|Snow/,
|
||||
pop_mode: true,
|
||||
});
|
||||
const Terrain = createToken({
|
||||
name: "Terrain",
|
||||
pattern: /(Electric|Grassy|Misty|Psychic) Terrain/,
|
||||
pop_mode: true,
|
||||
});
|
||||
|
||||
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,
|
||||
teraEnter,
|
||||
terrainEnter,
|
||||
screenEnter,
|
||||
],
|
||||
tera_mode: [whitespace, Tera],
|
||||
terrain_mode: [whitespace, 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 ${type}, got ${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 "Item":
|
||||
opts().item = unwrapToken("Item", item);
|
||||
break;
|
||||
case "Ability":
|
||||
opts().ability = unwrapToken("Ability", 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 "teraEnter":
|
||||
opts().teraType = unwrapToken("Tera", it.next().value);
|
||||
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.replace(" Terrain", "");
|
||||
break;
|
||||
default:
|
||||
throw Error(
|
||||
`Unhandled field condition ${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";
|
||||
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");
|
||||
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"
|
||||
);
|
||||
|
||||
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 };
|
8
calc_main.js
Normal file
8
calc_main.js
Normal file
@ -0,0 +1,8 @@
|
||||
import { parseAndCalculate } from "./calc.js";
|
||||
|
||||
// FIXME: issue with using execArgv.
|
||||
let args = process.argv.slice(2);
|
||||
if (args[0] === "--") args = args.slice(1);
|
||||
const line = args.join(" ");
|
||||
const res = parseAndCalculate(line);
|
||||
console.log(res.fullDesc());
|
45
facts.txt
Normal file
45
facts.txt
Normal file
@ -0,0 +1,45 @@
|
||||
The Canberra Mamoswines have fielded the most unique Pokémon, with 16. The Cheyenne Chimchars have fielded the least, with 6.
|
||||
The Raleigh County Revavrooms have drafted the most unique Pokémon, with 18. The Cheyenne Chimchars have drafted the least, with 10.
|
||||
Barrow's Biggest Birds have made the most free agency exchanges so far, with 2 exchanges totalling 6 members.
|
||||
Despite their name, Barrow's Biggest Birds do not currently possess any of largest birds in season 3, and have only had at most three birds on their roster. They briefly had the tallest drafted bird, Quaquaval, but exchanged them in week 3.
|
||||
The Raleigh County Revavrooms conducted the single largest free agency exchange of season 3 in week 3, replacing eight members of their roster.
|
||||
The Canberra Mamoswines are the slowest team, with an average of 65 Speed across their roster. The Buenos Aires Aggrons are the fastest, with an average of 84.6 Speed.
|
||||
The Nimbasa City Ninjasks have the greatest Speed differential between their fastest and slowest members, of 112 points. Emily's Eevees and the Raleigh County Revavrooms are tied for the lowest differential, of 72 points.
|
||||
The Raleigh County Revavrooms have the largest roster, with up to 13 bodies across their 10 members. Prior to week 6, the Buenos Aires Aggrons had the most heads, with 14.
|
||||
As of week 4, Emily's Eevees have the fewest limbs in season 3, with only 24. Only two of their members are in the Amporphous egg group.
|
||||
The Nimbasa City Ninjasks and the Scheveningen Slow Brows are tied for the most Toxic roster, with 2 Pokémon each capable of learning the move.
|
||||
Prior to week 6, Cerluedge had more kills than all other Fire-type Pokémon combined that aren't some kind of Volcarona, as befitting of its ability, Flash Fire.
|
||||
Landorus is the only Force of Nature that has not been drafted in season 3.
|
||||
No Therian-Forme Forces of Nature have been drafted in season 3.
|
||||
Emily's Eevees is the only roster to not have a Pokémon with more than 100 Speed.
|
||||
Most teams have an average member cost of 12 points. The San Francisco 549ers are most unevenly weighted team, with a standard distribution of 6.99 points. The Canberra Mamoswines are the most evenly weighted, with a standard distribution of 5.06 points.
|
||||
Only one team does not have a regional or alternate variant of a Pokémon on their roster: the Nimbasa City Ninjasks.
|
||||
The East Midland Milotics have the fattest roster, claiming both Dondozo and Bellibolt.
|
||||
Two teams have more than one 2-cost Pokémon in their rosters: Barrow's Biggest Birds and the Nimbasa City Ninjasks, each with 2 members. Four teams have no 2-cost Pokémon at all.
|
||||
Moltres is the only legendary bird that has not been drafted in season 3.
|
||||
Tyranitar and Haxorus are the only available pseudo-legendary Pokémon to not be drafted (either directly or as a variant) in season 3.
|
||||
Frosmoth is the biggest underdog in season 3 so far, having 4 knockouts to its name while only being a 2-point Pokémon.
|
||||
The Buenos Aires Aggrons and Canberra Mamoswines are tied for having the largest type overlap on their rosters: the Aggrons have 4 Water-types, and the Mamoswines have 4 Electric-types.
|
||||
6 Pokémon have been drafted more than once in season 3: Altaria, Amoongus, Gastrodon, Goodra-Hisui, Oricorio, and Quaquaval.
|
||||
help
|
||||
I have exactly 3 facts, and they're all about how fast teams are.
|
||||
6-0s happen more often than you'd think. It may even happen to you!
|
||||
Pivot moves make up approximately 12% of all moves used in season 3. This is because U-turn is the only pivoting move to be widely available outside Pokémon of the same type as the move. This will probably be fixed in generation 10.
|
||||
Brute Bonnet is the only ancient Paradox Pokémon to not be drafted in season 3 (among those available). However, Amoongus has been. They are very upset by this.
|
||||
Only two future Paradox Pokémon have not been drafted in season 3: Iron Jugulus and Iron Thorns. Iron Jugulus is resentful that Hydreigon has seen lots of play, but it hasn't.
|
||||
As usual, Flareon is the only original Eeveelution to not be drafted in season 3.
|
||||
After 5 generations of competitive relevance, Heatran is still weak to Earthquake.
|
||||
#holy-heck-pokemon-league contains the letters "OKHO" in order, so Discord suggests it when searching for OKHO.
|
||||
Donphan and Volcarona are the most-drafted Pokémon in season 3.
|
||||
252+ Atk Choice Band Quark Drive Iron Hands Wild Charge vs. 252 HP / 252+ Def Dondozo in Electric Terrain: 564-666 (111.9 - 132.1%) -- guaranteed OHKO
|
||||
Prior to week 6, Zapdos-G had a K/D ratio of 9:2, and both of its deaths were due to recoil.
|
||||
Wave Crash causes significant recoil because Floatzel travels at approximately 78392 miles per hour when it uses it. Something about slippery otters.
|
||||
Cyclizar
|
||||
Ice-types are topping the KO leaderboard. How did this happen?
|
||||
Froslass has not landed a damaging move in season 3, and has only inflicted damage through Spikes and Destiny Bond.
|
||||
People can be busy. Please allow 4-6 weeks for updated facts.
|
||||
The San Francisco 549ers and Scheveningen Slow Bros are tied for winning the most 6-0s of season 3, with two matches each.
|
||||
Barrow's Biggest Birds and Mew York are tied for winning the most games by surrender, with two matches each.
|
||||
No Pokémon has fainted due to Confusion in season 3.
|
||||
No Pokémon has led a single team in every match. However, 11 Pokémon have led in every match they have been fielded, and The Cheyenne Chimchars' Ting-Lu has the highest rate of leading, being fielded first in 66.7% of all of their games.
|
||||
Aside from the Cheyenne Chimchars, two teams are tied for the most staples (Pokémon fielded every single match): Mew York's Torkoal and Slither Wing, and the Scheveningen Slow Bros' Slowking-G and Corviknight.
|
@ -14,16 +14,13 @@
|
||||
let
|
||||
python = pkgs.python3.withPackages (ps: [
|
||||
ps.discordpy
|
||||
ps.mypy
|
||||
ps.requests
|
||||
ps.types-requests
|
||||
]);
|
||||
in
|
||||
[
|
||||
pkgs.nodejs
|
||||
python
|
||||
pkgs.sqlite
|
||||
python.pkgs.python-lsp-server
|
||||
python.pkgs.pylsp-mypy
|
||||
];
|
||||
};
|
||||
});
|
||||
|
@ -18,3 +18,6 @@ https://replay.pokemonshowdown.com/dl-gen9paldeadexposthomedraft-33751
|
||||
https://replay.pokemonshowdown.com/dl-gen9paldeadexposthomedraft-33998
|
||||
https://replay.pokemonshowdown.com/dl-gen9paldeadexposthomedraft-34087
|
||||
https://replay.pokemonshowdown.com/dl-gen9paldeadexposthomedraft-34672
|
||||
https://replay.pokemonshowdown.com/dl-gen9paldeadexposthomedraft-36025
|
||||
https://replay.pokemonshowdown.com/dl-gen9paldeadexposthomedraft-36096
|
||||
https://replay.pokemonshowdown.com/dl-gen9paldeadexposthomedraft-37058
|
||||
|
565
index.py
565
index.py
@ -14,22 +14,22 @@ import sys
|
||||
import typing as t
|
||||
|
||||
|
||||
logging.TRACE = 5 # type: ignore
|
||||
logging.addLevelName(logging.TRACE, "TRACE") # type: ignore
|
||||
logging.Logger.trace = partialmethod(logging.Logger.log, logging.TRACE) # type: ignore
|
||||
logging.trace = partial(logging.log, logging.TRACE) # type: ignore
|
||||
logging.TRACE = 5
|
||||
logging.addLevelName(logging.TRACE, "TRACE")
|
||||
logging.Logger.trace = partialmethod(logging.Logger.log, logging.TRACE)
|
||||
logging.trace = partial(logging.log, logging.TRACE)
|
||||
|
||||
|
||||
class LogFormatter(logging.Formatter):
|
||||
|
||||
_format = "%(name)s [%(levelname)s] %(message)s"
|
||||
format = "%(name)s [%(levelname)s] %(message)s"
|
||||
FORMATS = {
|
||||
logging.TRACE: f"\x1b[30;20m{_format}\x1b[0m", # type: ignore
|
||||
logging.DEBUG: f"\x1b[38;20m{_format}\x1b[0m",
|
||||
logging.INFO: f"\x1b[34;20m{_format}\x1b[0m",
|
||||
logging.WARNING: f"\x1b[33;20m{_format}\x1b[0m",
|
||||
logging.ERROR: f"\x1b[31;20m{_format}\x1b[0m",
|
||||
logging.CRITICAL: f"\x1b[31;1m{_format}\x1b[0m",
|
||||
logging.TRACE: f"\x1b[30;20m{format}\x1b[0m",
|
||||
logging.DEBUG: f"\x1b[38;20m{format}\x1b[0m",
|
||||
logging.INFO: f"\x1b[34;20m{format}\x1b[0m",
|
||||
logging.WARNING: f"\x1b[33;20m{format}\x1b[0m",
|
||||
logging.ERROR: f"\x1b[31;20m{format}\x1b[0m",
|
||||
logging.CRITICAL: f"\x1b[31;1m{format}\x1b[0m",
|
||||
}
|
||||
|
||||
def format(self, record):
|
||||
@ -45,6 +45,29 @@ _ch.setFormatter(LogFormatter())
|
||||
LOG.addHandler(_ch)
|
||||
|
||||
|
||||
TEAMS = {}
|
||||
_logged_teams = []
|
||||
|
||||
|
||||
def team(player: str) -> str:
|
||||
"""Maps a username to a defined team."""
|
||||
if player in TEAMS:
|
||||
return TEAMS[player]
|
||||
else:
|
||||
if not player in _logged_teams and player:
|
||||
LOG.warning(f"missing team mapping for {player}")
|
||||
_logged_teams.append(player)
|
||||
return player
|
||||
|
||||
|
||||
class safelist(list):
|
||||
def get(self, index, default=None):
|
||||
try:
|
||||
return self.__getitem__(index)
|
||||
except IndexError:
|
||||
return default
|
||||
|
||||
|
||||
def _init_db(conn: sqlite3.Connection):
|
||||
def namedtuple_factory(cursor, row):
|
||||
fields = [column[0] for column in cursor.description]
|
||||
@ -56,24 +79,23 @@ def _init_db(conn: sqlite3.Connection):
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS moves(
|
||||
game, turn, player, pokemon, move, target,
|
||||
UNIQUE(game, turn, player, pokemon)
|
||||
game, turn, player, name, user, target,
|
||||
UNIQUE(game, turn, player, user)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS switches(
|
||||
game, turn, player, pokemon,
|
||||
UNIQUE(game, turn, player, pokemon)
|
||||
game, turn, player, name,
|
||||
UNIQUE(game, turn, player, name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS nicknames(
|
||||
game, player, pokemon, specie,
|
||||
game, player, name, specie,
|
||||
UNIQUE(game, player, specie)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS knockouts(
|
||||
game, turn, player, pokemon,
|
||||
game, turn, player, name,
|
||||
UNIQUE(game, turn, player)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS indirect_knockouts(
|
||||
game, turn, player, pokemon,
|
||||
reason, source, source_player,
|
||||
game, turn, player, name, source, source_user, source_player,
|
||||
UNIQUE(game, turn, player)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS games(
|
||||
@ -82,377 +104,220 @@ def _init_db(conn: sqlite3.Connection):
|
||||
);
|
||||
-- No good way to ensure idempotence for damage; just re-build it.
|
||||
DROP TABLE IF EXISTS damage;
|
||||
CREATE TABLE damage(game, player, pokemon, value);
|
||||
CREATE TABLE damage(game, player, name, value);
|
||||
DROP TABLE IF EXISTS indirect_damage;
|
||||
CREATE TABLE indirect_damage(game, player, pokemon, value);
|
||||
CREATE TABLE indirect_damage(game, player, name, value);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
# Either the value "p1" or "p2"
|
||||
PlayerTag = t.NewType("PlayerTag", str)
|
||||
|
||||
# A player's name
|
||||
Player = t.NewType("Player", str)
|
||||
|
||||
# A player prefixed with a PlayerTag
|
||||
TaggedPlayer = t.NewType("TaggedPlayer", str)
|
||||
|
||||
# A Pokemon identified by its nickname, if any
|
||||
Pokemon = t.NewType("Pokemon", str)
|
||||
|
||||
# A Pokemon specie
|
||||
PokemonSpecie = t.NewType("PokemonSpecie", str)
|
||||
|
||||
# A Pokemon prefixed with a PlayerTag
|
||||
TaggedPokemon = t.NewType("TaggedPokemon", str)
|
||||
|
||||
|
||||
def tag(tagged: TaggedPlayer | TaggedPokemon) -> PlayerTag:
|
||||
return PlayerTag(tagged[0:1])
|
||||
|
||||
|
||||
TEAMS: dict[Player, Player] = {}
|
||||
_logged_teams: set[Player] = set()
|
||||
|
||||
|
||||
def team(player: Player) -> Player:
|
||||
"""Maps a username to a defined team."""
|
||||
if player in TEAMS:
|
||||
return TEAMS[player]
|
||||
else:
|
||||
if not player in _logged_teams and player:
|
||||
LOG.warning(f"missing team mapping for {player}")
|
||||
_logged_teams.add(player)
|
||||
return player
|
||||
|
||||
|
||||
class LogParser:
|
||||
def parse_log(game: str, log: str, into: sqlite3.Connection):
|
||||
conn = into
|
||||
|
||||
turn = 0
|
||||
players: dict[PlayerTag, Player] = {}
|
||||
hp: dict[TaggedPokemon, int] = {}
|
||||
players = {}
|
||||
hp = {}
|
||||
|
||||
# Memorises the user of the move that causes environment setting or status,
|
||||
# its target, and the move name (for debugging).
|
||||
last_move: t.Optional[tuple[TaggedPokemon, TaggedPokemon, str]] = None
|
||||
# ("p2a: Edward", "p1a: Meteo")
|
||||
# memorises the user of the move that causes environment setting or status,
|
||||
# and its target
|
||||
last_move: t.Optional[tuple[str, str]]
|
||||
|
||||
# Memorises the last hazard set against a player and the causing user.
|
||||
last_env_set: dict[tuple[PlayerTag, str], TaggedPokemon] = {}
|
||||
# ("p1", "Spikes") => "p2a: Frosslas"
|
||||
last_env_set: dict[tuple[str, str], str] = {}
|
||||
|
||||
# Memorises statuses set on a pokemon and the causing user.
|
||||
last_status_set: dict[tuple[TaggedPokemon, str], TaggedPokemon] = {}
|
||||
# ("p1a: Meteo", "brn") => "p2a: Edward"
|
||||
last_status_set: dict[tuple[str, str], str] = {}
|
||||
|
||||
def __init__(self, game: str, into: sqlite3.Connection):
|
||||
self.game = game
|
||||
self.conn: sqlite3.Connection = into
|
||||
def resolve_mon(user: str) -> tuple[str, str]:
|
||||
[player, name] = user.split(": ")
|
||||
return players[player.strip("ab")], name
|
||||
|
||||
def split_pokemon(self, user: TaggedPokemon) -> tuple[Player, Pokemon]:
|
||||
"""Splits a TaggedPokemon into the owning player and the Pokemon."""
|
||||
[player, pokemon] = user.split(": ")
|
||||
return self.players[PlayerTag(player.strip("ab"))], Pokemon(pokemon)
|
||||
for line in log.split("\n"):
|
||||
chunks = line.split("|")[1:]
|
||||
if not chunks:
|
||||
continue
|
||||
|
||||
@t.overload
|
||||
def specie(self, pokemon: Pokemon, player: Player) -> PokemonSpecie:
|
||||
"""Resolves the species of a nicknamed Pokemon."""
|
||||
...
|
||||
LOG.trace(line)
|
||||
|
||||
@t.overload
|
||||
def specie(self, pokemon: TaggedPokemon) -> PokemonSpecie:
|
||||
"""Resolves the species of a Pokemon given its Showdown identifier (used
|
||||
in split_pokemon)."""
|
||||
...
|
||||
match chunks:
|
||||
case ["player", id, username, *rest]:
|
||||
players[id] = username
|
||||
|
||||
def specie(
|
||||
self, pokemon: Pokemon | TaggedPokemon, player: t.Optional[Player] = None
|
||||
) -> PokemonSpecie:
|
||||
if not player:
|
||||
[player, pokemon] = self.split_pokemon(TaggedPokemon(pokemon))
|
||||
return (
|
||||
self.conn.execute(
|
||||
"""
|
||||
SELECT specie
|
||||
FROM nicknames
|
||||
WHERE (game, player, pokemon) = (?, ?, ?)
|
||||
LIMIT 1
|
||||
""",
|
||||
(self.game, team(player), pokemon),
|
||||
)
|
||||
.fetchall()[0]
|
||||
.specie
|
||||
)
|
||||
case ["turn", turn]:
|
||||
turn = int(turn)
|
||||
|
||||
def _reset(self):
|
||||
self.turn = 0
|
||||
self.players.clear()
|
||||
self.hp.clear()
|
||||
self.last_move = None
|
||||
self.last_env_set.clear()
|
||||
self.last_status_set.clear()
|
||||
case ["move", user, move, target]:
|
||||
last_move = (user, target)
|
||||
player, user = resolve_mon(user)
|
||||
_, target = resolve_mon(target)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO moves(game, turn, player, name, user, target)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(game, turn, team(player), move, user, target),
|
||||
)
|
||||
|
||||
def _log_appearance(self, name: TaggedPokemon, specie: str):
|
||||
case ["drag", name, specie, status, *rest]:
|
||||
hp[name] = int(status.split("/")[0])
|
||||
|
||||
# Also includes gender and formes.
|
||||
trimmed_specie = PokemonSpecie(specie.split(", ")[0])
|
||||
player, nickname = self.split_pokemon(name)
|
||||
case ["switch", name, specie, status, *rest]:
|
||||
hp[name] = int(status.split("/")[0])
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO nicknames(game, player, pokemon, specie)
|
||||
VALUES(?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(self.game, team(player), nickname, trimmed_specie),
|
||||
)
|
||||
player, name = resolve_mon(name)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO switches(game, turn, player, name)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(game, turn, team(player), name),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO nicknames(game, player, name, specie)
|
||||
VALUES(?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(game, team(player), name, specie.split(", ")[0]),
|
||||
)
|
||||
|
||||
def parse(self, log: str):
|
||||
self._reset()
|
||||
case ["faint", mon]:
|
||||
player, mon = resolve_mon(mon)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO knockouts(game, turn, player, name)
|
||||
VALUES(?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(game, turn, team(player), mon),
|
||||
)
|
||||
|
||||
for line in log.split("\n"):
|
||||
chunks = line.split("|")[1:]
|
||||
if not chunks:
|
||||
continue
|
||||
case ["win", player]:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE games
|
||||
SET winner = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(team(player), game),
|
||||
)
|
||||
|
||||
LOG.trace(line) # type: ignore
|
||||
case ["-sidestart", side, env]:
|
||||
if not last_move:
|
||||
LOG.warning(f"missing previous move for {line}")
|
||||
continue
|
||||
LOG.debug(f"{line} <- {last_move}")
|
||||
last_env_set[(side[0:1], env.replace("move: ", ""))] = last_move[0]
|
||||
|
||||
match chunks:
|
||||
case ["-status", mon, cond]:
|
||||
if not last_move or last_move[1] != mon:
|
||||
LOG.warning(f"missing previous move for {line}")
|
||||
continue
|
||||
LOG.debug(f"{line} <- {last_move}")
|
||||
last_status_set[(mon, cond)] = last_move[0]
|
||||
|
||||
# t.Literal, TaggedPokemon, str, str
|
||||
case ["drag", name_, specie, status, *rest]:
|
||||
name = TaggedPokemon(name_)
|
||||
case ["-damage", mon, status]:
|
||||
# mon takes direct (non-hazard/condition) damage
|
||||
# status can be a percentage 70/100 with or without condition,
|
||||
# or "0 fnt"
|
||||
new_hp = int(re.split("[/ ]", status)[0])
|
||||
LOG.debug(f"{mon} dropped to {new_hp} from {hp[mon]}")
|
||||
LOG.debug(f"source: {last_move}")
|
||||
|
||||
self.hp[name] = int(status.split("/")[0])
|
||||
self._log_appearance(name, specie)
|
||||
# resolve to damage source
|
||||
if last_move[1] != mon:
|
||||
LOG.warn(
|
||||
f"{mon} took direct damage but last move was not targeted at them"
|
||||
)
|
||||
continue
|
||||
user = last_move[0]
|
||||
source_player, source_mon = resolve_mon(user)
|
||||
|
||||
# t.Literal, TaggedPokemon
|
||||
case ["faint", pokemon_]:
|
||||
pokemon = TaggedPokemon(pokemon_)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO damage(game, player, name, value)
|
||||
VALUES(?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(game, team(source_player), source_mon, hp[mon] - new_hp),
|
||||
)
|
||||
|
||||
player, _ = self.split_pokemon(pokemon)
|
||||
self.conn.execute(
|
||||
hp[mon] = new_hp
|
||||
|
||||
case ["-damage", mon, status, from_]:
|
||||
# mon takes indirect damage
|
||||
# status can be a percentage 70/100 with or without condition,
|
||||
# or "0 fnt"
|
||||
# mon has fainted from an indirect damage source
|
||||
#
|
||||
new_hp = int(re.split("[/ ]", status)[0])
|
||||
LOG.debug(f"{mon} dropped to {new_hp} from {from_}")
|
||||
|
||||
LOG.debug(f"tracing source for {line}")
|
||||
source = from_.replace("[from] ", "")
|
||||
source_user = None
|
||||
|
||||
test_hazard = last_env_set.get((mon[0:1], source))
|
||||
if test_hazard:
|
||||
source_user = test_hazard
|
||||
LOG.debug(f"identified hazard source {source_user}")
|
||||
|
||||
test_status = last_status_set.get((mon, source))
|
||||
if test_status:
|
||||
source_user = test_status
|
||||
LOG.debug(f"identified move source {source_user}")
|
||||
|
||||
if source == "Recoil" or source.startswith("item: "):
|
||||
LOG.debug(f"identified special source {source}")
|
||||
source = source.replace("item: ", "")
|
||||
source_user = "self"
|
||||
|
||||
if not source_user:
|
||||
LOG.error(f"missing source for {line}")
|
||||
continue
|
||||
|
||||
player, pkmn = resolve_mon(mon)
|
||||
if source_user.startswith("p1") or source_user.startswith("p2"):
|
||||
source_player, source_mon = resolve_mon(source_user)
|
||||
else:
|
||||
source_player = None
|
||||
|
||||
if source_player:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO knockouts(game, turn, player, pokemon)
|
||||
INSERT INTO indirect_damage(game, player, name, value)
|
||||
VALUES(?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(self.game, self.turn, team(player), self.specie(pokemon)),
|
||||
(game, team(source_player), source_mon, hp[mon] - new_hp),
|
||||
)
|
||||
|
||||
# t.Literal, TaggedPokemon, str, TaggedPokemon
|
||||
case ["move", user_, move, target_]:
|
||||
user = TaggedPokemon(user_)
|
||||
target = TaggedPokemon(target_)
|
||||
|
||||
last_move = (user, target, move)
|
||||
player, _ = self.split_pokemon(user)
|
||||
self.conn.execute(
|
||||
if status == "0 fnt":
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO moves(game, turn, player, pokemon, move, target)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO indirect_knockouts(game, turn, player, name, source, source_user, source_player)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(
|
||||
self.game,
|
||||
self.turn,
|
||||
game,
|
||||
turn,
|
||||
team(player),
|
||||
self.specie(user),
|
||||
move,
|
||||
self.specie(target),
|
||||
),
|
||||
)
|
||||
|
||||
# t.Literal, PlayerTag, Player
|
||||
case ["player", id, username, *rest]:
|
||||
self.players[PlayerTag(id)] = Player(username)
|
||||
|
||||
# t.Literal, TaggedPokemon, str
|
||||
case ["replace", name, specie]:
|
||||
self._log_appearance(name, specie)
|
||||
|
||||
# t.Literal, TaggedPokemon, str, str, t.Optional[str]
|
||||
case ["switch", name, specie, status, *rest]:
|
||||
self.hp[name] = int(status.split("/")[0])
|
||||
|
||||
# Also includes gender and formes.
|
||||
trimmed_specie = specie.split(", ")[0]
|
||||
player, nickname = self.split_pokemon(name)
|
||||
|
||||
self._log_appearance(name, specie)
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO switches(game, turn, player, pokemon)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(self.game, self.turn, team(player), trimmed_specie),
|
||||
)
|
||||
|
||||
# t.Literal, str
|
||||
case ["turn", turn]:
|
||||
self.turn = int(turn)
|
||||
|
||||
# t.Literal, Player
|
||||
case ["win", player]:
|
||||
self.conn.execute(
|
||||
"""
|
||||
UPDATE games
|
||||
SET winner = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(team(player), self.game),
|
||||
)
|
||||
|
||||
case ["-heal", pokemon, status, *rest]:
|
||||
self.hp[pokemon] = int(status.split("/")[0])
|
||||
|
||||
# TODO: track healing done
|
||||
|
||||
# t.Literal, TaggedPokemon, str
|
||||
case ["-damage", pokemon, status]:
|
||||
# Pokemon takes direct (non-hazard/condition) damage; status
|
||||
# can be a percentage "70/100" with or without condition, or
|
||||
# "0 fnt"
|
||||
new_hp = int(re.split("[/ ]", status)[0])
|
||||
LOG.debug(f"{pokemon} dropped to {new_hp} from {self.hp[pokemon]}")
|
||||
LOG.debug(f"source: {last_move}")
|
||||
|
||||
# resolve to damage source
|
||||
if last_move[1] != pokemon:
|
||||
LOG.warning(
|
||||
f"{pokemon} took direct damage but last move"
|
||||
f" {last_move[2]} was not targeted at them"
|
||||
)
|
||||
continue
|
||||
damage_source = last_move[0]
|
||||
source_player, source_nickname = self.split_pokemon(damage_source)
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO damage(game, player, pokemon, value)
|
||||
VALUES(?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(
|
||||
self.game,
|
||||
pkmn,
|
||||
source,
|
||||
source_mon,
|
||||
team(source_player),
|
||||
self.specie(damage_source),
|
||||
self.hp[pokemon] - new_hp,
|
||||
),
|
||||
)
|
||||
|
||||
self.hp[pokemon] = new_hp
|
||||
case ["-heal", mon, status, *rest]:
|
||||
hp[mon] = int(status.split("/")[0])
|
||||
|
||||
# t.Literal, TaggedPokemon, str, str
|
||||
case ["-damage", pokemon_, status, from_]:
|
||||
pokemon = TaggedPokemon(pokemon_)
|
||||
|
||||
# Pokemon takes indirect damage; status can be a percentage
|
||||
# "70/100" with or without condition, or "0 fnt"
|
||||
new_hp = int(re.split("[/ ]", status)[0])
|
||||
LOG.debug(f"{pokemon} dropped to {new_hp} from {from_}")
|
||||
|
||||
LOG.debug(f"tracing reason for {line}")
|
||||
reason = from_.replace("[from] ", "")
|
||||
|
||||
source: TaggedPokemon | str | None = None
|
||||
source_is_pokemon = True
|
||||
|
||||
test_hazard = self.last_env_set.get((tag(pokemon), reason))
|
||||
if test_hazard:
|
||||
source = test_hazard
|
||||
LOG.debug(f"identified hazard source {source}")
|
||||
|
||||
test_status = self.last_status_set.get((pokemon, reason))
|
||||
if test_status:
|
||||
source = test_status
|
||||
LOG.debug(f"identified move source {source}")
|
||||
|
||||
if reason == "Recoil" or reason.startswith("item: "):
|
||||
LOG.debug(f"identified special source {reason}")
|
||||
reason = reason.replace("item: ", "")
|
||||
source = "self"
|
||||
source_is_pokemon = False
|
||||
|
||||
if not source:
|
||||
LOG.error(f"missing reason for {line}")
|
||||
continue
|
||||
|
||||
player, nickname = self.split_pokemon(pokemon)
|
||||
if source.startswith("p1") or source.startswith("p2"):
|
||||
source_player, _ = self.split_pokemon(TaggedPokemon(source))
|
||||
else:
|
||||
source_player = None # type: ignore
|
||||
source_is_pokemon = False
|
||||
|
||||
if source_player:
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO indirect_damage(game, player, pokemon, value)
|
||||
VALUES(?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
self.game,
|
||||
team(source_player),
|
||||
self.specie(TaggedPokemon(source)),
|
||||
self.hp[pokemon] - new_hp,
|
||||
),
|
||||
)
|
||||
|
||||
if status == "0 fnt":
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO indirect_knockouts(
|
||||
game, turn, player, pokemon,
|
||||
reason, source, source_player)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(
|
||||
self.game,
|
||||
self.turn,
|
||||
team(player),
|
||||
self.specie(pokemon),
|
||||
reason,
|
||||
self.specie(TaggedPokemon(source))
|
||||
if source_is_pokemon
|
||||
else source,
|
||||
team(source_player),
|
||||
),
|
||||
)
|
||||
|
||||
# t.Literal, TaggedPlayer, str
|
||||
case ["-sidestart", side_, env]:
|
||||
side = TaggedPlayer(side_)
|
||||
|
||||
if not last_move:
|
||||
LOG.warning(f"missing previous move for {line}")
|
||||
continue
|
||||
|
||||
LOG.debug(f"{line} <- {last_move}")
|
||||
self.last_env_set[
|
||||
(tag(side), env.replace("move: ", ""))
|
||||
] = last_move[0]
|
||||
|
||||
# t.Literal, TaggedPokemon, str
|
||||
case ["-status", pokemon_, cond]:
|
||||
pokemon = TaggedPokemon(pokemon_)
|
||||
|
||||
if not last_move or last_move[1] != pokemon:
|
||||
LOG.warning(f"missing previous move for {line}")
|
||||
continue
|
||||
|
||||
LOG.debug(f"{line} <- {last_move}")
|
||||
self.last_status_set[(pokemon, cond)] = last_move[0]
|
||||
|
||||
# t.Literal, TaggedPokemon, str
|
||||
case ["-terastallize", pokemon_, type]:
|
||||
pokemon = TaggedPokemon(pokemon_)
|
||||
# TODO
|
||||
pass
|
||||
|
||||
case _:
|
||||
# LOG.debug(f"unhandled message {chunks[0]}")
|
||||
pass
|
||||
case _:
|
||||
# LOG.debug(f"unhandled message {chunks[0]}")
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -490,7 +355,7 @@ def fetch(replay: str, cache: bool = True) -> Replay:
|
||||
with replay_file.open(mode="w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
return Replay(**data) # type: ignore
|
||||
return Replay(**data)
|
||||
|
||||
|
||||
def main():
|
||||
@ -562,7 +427,7 @@ def main():
|
||||
),
|
||||
)
|
||||
|
||||
LogParser(replay.id, db).parse(replay.log)
|
||||
parse_log(replay.id, replay.log, into=db)
|
||||
db.commit()
|
||||
|
||||
finally:
|
||||
|
82
package-lock.json
generated
Normal file
82
package-lock.json
generated
Normal file
@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "hhirls",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hhirls",
|
||||
"version": "1.0.0",
|
||||
"license": "WTFPL",
|
||||
"dependencies": {
|
||||
"@ajhyndman/smogon-calc": "^0.8.0",
|
||||
"chevrotain": "^10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ajhyndman/smogon-calc": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@ajhyndman/smogon-calc/-/smogon-calc-0.8.0.tgz",
|
||||
"integrity": "sha512-jx/gY1uSD70skzE0xE7+lUPp7KxKKqj3PcyRLMCejbd9A68PwnBKfbDP3pW5ILX9Dy+9R9m6OkiPCa9K4qi/pQ==",
|
||||
"dependencies": {
|
||||
"@types/node": "^18.14.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/cst-dts-gen": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz",
|
||||
"integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==",
|
||||
"dependencies": {
|
||||
"@chevrotain/gast": "10.5.0",
|
||||
"@chevrotain/types": "10.5.0",
|
||||
"lodash": "4.17.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/gast": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz",
|
||||
"integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "10.5.0",
|
||||
"lodash": "4.17.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/types": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz",
|
||||
"integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A=="
|
||||
},
|
||||
"node_modules/@chevrotain/utils": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz",
|
||||
"integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "18.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.1.tgz",
|
||||
"integrity": "sha512-DZxSZWXxFfOlx7k7Rv4LAyiMroaxa3Ly/7OOzZO8cBNho0YzAi4qlbrx8W27JGqG57IgR/6J7r+nOJWw6kcvZA=="
|
||||
},
|
||||
"node_modules/chevrotain": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz",
|
||||
"integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==",
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "10.5.0",
|
||||
"@chevrotain/gast": "10.5.0",
|
||||
"@chevrotain/types": "10.5.0",
|
||||
"@chevrotain/utils": "10.5.0",
|
||||
"lodash": "4.17.21",
|
||||
"regexp-to-ast": "0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/regexp-to-ast": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz",
|
||||
"integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="
|
||||
}
|
||||
}
|
||||
}
|
17
package.json
Normal file
17
package.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "hhirls",
|
||||
"version": "1.0.0",
|
||||
"description": "Pokemon Showdown data processing, mostly for HHIRLLL's Pokemon league. Ugly as fuck.",
|
||||
"main": "calc.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"calc": "node calc_main.js",
|
||||
"test": "node -e 'import(\"./calc.js\").then(mod => mod.test())'"
|
||||
},
|
||||
"author": "",
|
||||
"license": "WTFPL",
|
||||
"dependencies": {
|
||||
"@ajhyndman/smogon-calc": "^0.8.0",
|
||||
"chevrotain": "^10.5.0"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user