Compare commits

...

14 Commits

Author SHA1 Message Date
70141b162f
fix typing and generation-based matching 2023-04-27 10:38:19 +10:00
6d92cb4deb
add pre-checking 2023-04-27 09:51:42 +10:00
32e142b8cb
fix gen 2023-04-27 09:51:41 +10:00
c6249b1b9b
use smogon calc re-publish 2023-04-27 09:45:47 +10:00
4f1e84ba8e
auto-delete error messages 2023-04-26 17:31:51 +10:00
225cac02c0
use smogon calc from master 2023-04-26 17:28:56 +10:00
a1f2f175cd
fix error forwarding 2023-04-26 17:20:44 +10:00
4b07aeb10c
fix formatting 2023-04-26 17:19:36 +10:00
699a833285
fix parse and reply 2023-04-26 17:16:48 +10:00
648deb83db
handle error 2023-04-26 17:12:24 +10:00
a926461f8f
fix args 2023-04-26 17:07:30 +10:00
a65137307b
fix self 2023-04-26 17:04:36 +10:00
0afca583b0
fix command parsing 2023-04-26 17:03:46 +10:00
dc668c67a0
add Showdown calculator 2023-04-26 17:01:02 +10:00
7 changed files with 588 additions and 2 deletions

145
.gitignore vendored
View File

@ -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

28
bot.py
View File

@ -61,11 +61,37 @@ class BotClient(discord.Client):
async def on_message(self, message: discord.Message):
content = message.content
if self.is_replay(message):
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 message.content.split(" "):
case ["%calc", *args]:
try:
calc = await self._calculate(args)
await message.reply(content=calc.strip())
except Exception as e:
await message.reply(
content="```\n" + str(e) + "\n```", delete_after=30
)
case _:
_log.info(f"Unrecognised command {command}")
async def _calculate(self, args: list[str]) -> str:
proc = sp.run(
["node", "calc_main.js", "--"] + args, stdout=sp.PIPE, stderr=sp.PIPE
)
if proc.returncode != 0:
raise Exception(proc.stderr.decode())
return proc.stdout.decode()
def is_replay(self, message: discord.Message) -> bool:
if re.match("https://replay.pokemonshowdown.com/dl-.*", message.content):
return True

304
calc.js Normal file
View File

@ -0,0 +1,304 @@
import {
calculate,
Field,
Generations,
Move,
Pokemon,
Result,
} from "@ajhyndman/smogon-calc";
import assert from "assert";
import { createToken, Lexer } from "chevrotain";
const gen = Generations.get(9);
/**
* 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) => i.name).join("|")),
});
const Ability = createToken({
name: "Ability",
pattern: new RegExp([...gen.abilities].map((a) => a.name).join("|")),
});
const Pokemon = createToken({
name: "Pokemon",
pattern: new RegExp([...gen.species].map((s) => s.name).join("|")),
});
const Move = createToken({
name: "Move",
pattern: new RegExp([...gen.moves].map((m) => m.name).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 terrainEnter = createToken({
name: "terrainEnter",
pattern: "in",
push_mode: "terrain_mode",
});
const Weather = createToken({
name: "Weather",
pattern: /Sun|Rain|Sand|Snow/,
});
const Terrain = createToken({
name: "Terrain",
pattern: /(Electric|Grassy|Misty|Psychic) Terrain/,
});
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,
terrainEnter,
screenEnter,
],
terrain_mode: [whitespace, screenEnter, 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 %s, got %s",
type,
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 "Stat":
throw Error("Impossible state: bare Stat");
case "Item":
opts().item = unwrapToken("Item", 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 "terrainEnter":
{
let next = it.next().value;
switch (next.tokenType.name) {
case "Weather":
field.weather = next.image;
break;
case "Terrain":
field.terrain = next.image;
break;
default:
throw Error(
"Unhandled terrain %s: %s",
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(attacker.toLowerCase()))
throw Error(`No species named ${attacker}`);
if (!gen.species.get(defender.toLowerCase()))
throw Error(`No species named ${defender}`);
if (!gen.moves.get(move.toLowerCase())) 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";
const 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"
);
}
export { parseAndCalculate, test };

8
calc_main.js Normal file
View 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());

View File

@ -17,7 +17,11 @@
ps.requests
]);
in
[ python pkgs.sqlite ];
[
pkgs.nodejs
python
pkgs.sqlite
];
};
});
}

82
package-lock.json generated Normal file
View 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
View 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"
}
}