Ruby-Cogs/pokemonduel/commands.py
2025-04-02 22:57:51 -04:00

483 lines
21 KiB
Python

import discord
from redbot.core import commands
from redbot.core import Config
import asyncio
import aiohttp
import logging
from .battle import Battle
from .buttons import DuelAcceptView
from .pokemon import DuelPokemon
from .data import generate_team_preview, find, find_one
from .trainer import MemberTrainer, NPCTrainer
class TeambuilderReadException(Exception):
"""Generic exception raised when failing to parse a teambuilder export string."""
pass
class PokemonDuel(commands.Cog):
"""Battle in a Pokemon Duel with another member of your server."""
def __init__(self, bot):
self.bot = bot
self.log = logging.getLogger('red.flamecogs.pokemonduel')
self.games = {}
self.config = Config.get_conf(self, identifier=145519400223506432)
self.config.register_member(
party = [],
)
self.config.register_guild(
useThreads = False,
)
@staticmethod
async def party_from_teambuilder(ctx, teambuilder):
"""
Builds a party from an exported pokemon showdown teambuilder team.
https://play.pokemonshowdown.com/teambuilder
"""
teambuilder = teambuilder.strip()
party = []
for raw in teambuilder.split("\n\n"):
raw = raw.strip()
lines = raw.split("\n")
# FIRST LINE
nameraw = lines.pop(0)
item = "None"
if "@" in nameraw:
nameraw, item = nameraw.split("@")
item = item.strip().replace(" ", "-").lower()
gender = "-m"
if "(M)" in nameraw:
nameraw = nameraw.replace("(M)", "")
gender = "-m"
elif "(F)" in nameraw:
nameraw = nameraw.replace("(F)", "")
gender = "-f"
nick = "None"
if "(" in nameraw:
nick, nameraw = nameraw.split("(")
nameraw = nameraw.strip()[:-1]
nick = nick.strip()
pokname = nameraw.strip().replace(" ", "-").capitalize()
if pokname == "nidoran":
name += gender
forms = await find_one(ctx, "forms", {"identifier": pokname.lower()})
if forms is None:
raise TeambuilderReadException(f"`{pokname}` is not a valid pokemon.")
pfile = await find_one(ctx, "pfile", {"id": forms["base_id"]})
if pfile is None:
raise TeambuilderReadException(f"Could not find a `pfile` entry for `{pokname}`. Please report this bug.")
gender_rate = pfile["gender_rate"]
if gender_rate == 0:
gender = "-m"
elif gender_rate == 8:
gender = "-f"
elif gender_rate == -1:
gender = "-x"
# REST OF THE LINES
hpiv = 31
atkiv = 31
defiv = 31
spatkiv = 31
spdefiv = 31
speediv = 31
hpev = 0
atkev = 0
defev = 0
spatkev = 0
spdefev = 0
speedev = 0
level = 100
happiness = 255
ability_index = 0
shiny = False
nature = "Hardy"
moves = []
for line in lines:
line = line.strip()
if line.startswith("IVs:"):
line = line[4:].strip()
ivs = line.split("/")
for iv in ivs:
amount, iv = iv.strip().split(" ")
amount = int(amount)
iv = iv.lower()
if iv == "hp":
hpiv = amount
elif iv == "atk":
atkiv = amount
elif iv == "def":
defiv = amount
elif iv == "spa":
spatkiv = amount
elif iv == "spd":
spdefiv = amount
elif iv == "spe":
speediv = amount
elif line.startswith("EVs:"):
line = line[4:].strip()
evs = line.split("/")
for ev in evs:
amount, ev = ev.strip().split(" ")
amount = int(amount)
ev = ev.lower()
if ev == "hp":
hpev = amount
elif ev == "atk":
atkev = amount
elif ev == "def":
defev = amount
elif ev == "spa":
spatkev = amount
elif ev == "spd":
spdefev = amount
elif ev == "spe":
speedev = amount
elif line.startswith("Shiny:"):
if "Yes" in line:
shiny = True
elif line.startswith("Level:"):
line = line[6:].strip()
level = int(line)
elif line.startswith("Happiness:"):
line = line[10:].strip()
happiness = int(line)
elif line.endswith("Nature"):
line = line[:-6].strip()
nature = line.capitalize()
elif line.startswith("Ability:"):
ability = line[8:].strip().lower().replace(" ", "-")
ability_raw = await find_one(ctx, "abilities", {"identifier": ability})
if ability_raw is None:
raise TeambuilderReadException(f"`{pokname}` was given an ability `{ability}` which does not exist.")
ability_id = ability_raw["id"]
abilities = await find(ctx, "poke_abilities", {"pokemon_id": forms["pokemon_id"]})
abilities = [a["ability_id"] for a in abilities]
if ability_id not in abilities:
raise TeambuilderReadException(f"`{pokname}` can not have the ability `{ability}`.")
ability_index = abilities.index(ability_id)
elif line.startswith("-"):
line = line[1:].split("/")[0].strip()
move = line.lower().replace(" ", "-")
if move.startswith("hidden-power"):
move = "hidden-power"
if await find_one(ctx, "moves", {"identifier": move}) is None:
raise TeambuilderReadException(f"`{pokname}` was given a move `{move}` which does not exist.")
moves.append(move)
elif line.startswith("Hidden Power:"):
pass
elif line.startswith("Tera Type:"):
pass # TODO: figure out how to handle teras
else:
raise TeambuilderReadException(f"Data line `{line[:200]}` is not properly formatted.")
if len(moves) != 4:
raise TeambuilderReadException(f"`{pokname}` was given {len(moves)} moves. It must have exactly 4 moves.")
evsum = sum([hpev, atkev, defev, spatkev, spdefev, speedev])
if evsum > 510:
raise TeambuilderReadException(f"`{pokname}` was given {evsum} EV points. It must have no more than 510 EV points.")
for s in [hpiv, atkiv, defiv, spatkiv, spdefiv, speediv]:
if s > 31 or s < 0:
raise TeambuilderReadException(f"`{pokname}` was given an IV stat of {s}. IVs must be between 0 and 31.")
for s in [hpev, atkev, defev, spatkev, spdefev, speedev]:
if s > 252 or s < 0:
raise TeambuilderReadException(f"`{pokname}` was given an EV stat of {s}. EVs must be between 0 and 252.")
if item != "None":
item_raw = await find_one(ctx, "items", {"identifier": item})
if item_raw is None:
raise TeambuilderReadException(f"`{pokname}` was given an item `{item}` which does not exist.")
if item in (
"venusaurite", "blastoisinite", "alakazite", "gengarite", "kangaskhanite", "pinsirite",
"gyaradosite", "aerodactylite", "ampharosite", "scizorite", "heracronite", "houndoominite",
"tyranitarite", "blazikenite", "gardevoirite", "mawilite", "aggronite", "medichamite",
"manectite", "banettite", "absolite", "latiasite", "latiosite", "garchompite", "lucarionite",
"abomasite", "beedrillite", "pidgeotite", "slowbronite", "steelixite", "sceptilite",
"swampertite", "sablenite", "sharpedonite", "cameruptite", "altarianite", "glalitite",
"salamencite", "metagrossite", "lopunnite", "galladite", "audinite", "diancite",
):
item = "mega-stone"
elif item in ("charizardite-x", "mewtwonite-x"):
item = "mega-stone-x"
elif item in ("charizardite-y", "mewtwonite-y"):
item = "mega-stone-y"
if nature not in (
"Hardy", "Bold", "Modest", "Calm", "Timid", "Lonely", "Docile", "Mild", "Gentle", "Hasty", "Adamant", "Impish", "Bashful",
"Careful", "Jolly", "Naughty", "Lax", "Rash", "Quirky", "Naive", "Brave", "Relaxed", "Quiet", "Sassy", "Serious",
):
raise TeambuilderReadException(f"`{pokname}` was given a nature `{nature}` which does not exist.")
if level > 100 or level < 1:
raise TeambuilderReadException(f"`{pokname}` was given a level of {level}. Its level must be between 1 and 100.")
if happiness < 0:
raise TeambuilderReadException(f"`{pokname}` was given a happiness of {happiness}. Its happiness must be at least 0.")
pokemon = {
'id': 0,
'pokname': pokname,
'hpiv': hpiv,
'atkiv': atkiv,
'defiv': defiv,
'spatkiv': spatkiv,
'spdefiv': spdefiv,
'speediv': speediv,
'hpev': hpev,
'atkev': atkev,
'defev': defev,
'spatkev': spatkev,
'spdefev': spdefev,
'speedev': speedev,
'moves': moves,
'hitem': item,
'nature': nature,
'poknick': nick,
'pokelevel': level,
'happiness': happiness,
'ability_index': ability_index,
'gender': gender,
'shiny': shiny,
'radiant': False,
'skin': None,
}
party.append(pokemon)
if len(party) < 1 or len(party) > 6:
raise TeambuilderReadException(f"Your party has {len(party)} pokemon. It must have 1 to 6 pokemon.")
return party
async def wrapped_run(self, battle):
"""
Runs the provided battle, handling any errors that are raised.
Returns the output of the battle, or None if the battle errored.
"""
self.games[int(battle.ctx.message.id)] = battle
try:
winner = await battle.run()
except (aiohttp.client_exceptions.ClientOSError, asyncio.TimeoutError):
await battle.channel.send(
"The bot encountered an unexpected network issue, "
"and the duel could not continue. "
"Please try again in a few moments.\n"
"Note: Do not report this as a bug."
)
return None
except Exception as exc:
msg = 'Error in PokemonDuel.\n'
self.log.exception(msg)
self.bot.dispatch('flamecogs_game_error', battle, exc)
await battle.channel.send(
'A fatal error has occurred, shutting down.\n'
'Please have the bot owner copy the error from console '
'and post it in the support channel of <https://discord.gg/bYqCjvu>.'
)
return None
else:
if int(battle.ctx.message.id) in self.games:
del self.games[int(battle.ctx.message.id)]
return winner
@commands.group(aliases=["pokeduel"], invoke_without_command=True)
@commands.bot_has_permissions(attach_files=True, embed_links=True)
async def pokemonduel(self, ctx, opponent: discord.Member):
"""Battle in a Pokemon Duel with another member of your server."""
await self._start_duel(ctx, opponent)
@pokemonduel.command()
async def inverse(self, ctx, opponent: discord.Member):
"""Battle in an Inverse Duel with another member of your server."""
await self._start_duel(ctx, opponent, inverse_battle=True)
async def _start_duel(self, ctx, opponent: discord.Member, *, inverse_battle=False):
"""Runs a duel."""
if opponent.id == ctx.author.id:
await ctx.send("You cannot duel yourself!")
return
if opponent.bot:
await ctx.send("You cannot duel a bot!")
return
view = DuelAcceptView(ctx, opponent)
battle_type = "an inverse battle" if inverse_battle else "a duel"
initial_message = await ctx.send(
f"{opponent.mention} You have been challenged to {battle_type} by {ctx.author.name}!\n",
view=view
)
view.message = initial_message
channel = ctx.channel
if (
await self.config.guild(ctx.guild).useThreads()
and ctx.channel.permissions_for(ctx.guild.me).create_public_threads
and ctx.channel.type is discord.ChannelType.text
):
try:
channel = await initial_message.create_thread(
name='PokemonDuel',
reason='Automated thread for PokemonDuel.',
)
except discord.HTTPException:
pass
await view.wait()
if not view.confirm:
return
trainers = []
for player in (ctx.author, opponent):
party = await self.config.member(player).party()
if not party:
await channel.send(f"{player} has not setup their party yet!\nSet one with `{ctx.prefix}pokemonduel party set`.")
return
party = [await DuelPokemon.create(ctx, p) for p in party]
trainers.append(MemberTrainer(player, party))
battle = Battle(ctx, channel, *trainers, inverse_battle=inverse_battle) # pylint: disable=E1120
preview_view = await generate_team_preview(battle)
await battle.trainer1.event.wait()
await battle.trainer2.event.wait()
preview_view.stop()
winner = await self.wrapped_run(battle)
@pokemonduel.group()
async def party(self, ctx):
"""Manage your party of pokemon."""
pass
@party.command(name="set")
async def party_set(self, ctx, *, pokemon_data):
"""
Set your party of pokemon.
In order to set your party, you will need to create a team on Pokemon Showdown Team Builder.
1. Go to the [Team Builder site](https://play.pokemonshowdown.com/teambuilder).
2. Click the "New Team" button.
3. Select the format "Anything Goes".
4. Use the "Add Pokemon" button to create a new pokemon.
5. Pick its moves, ability, gender, level, etc.
6. Repeat steps 4 and 5 for up to 6 total pokemon
7. On the team view, select the "Import/Export" button at the TOP.
8. Copy the text provided, and pass that to this command.
"""
try:
party = await self.party_from_teambuilder(ctx, pokemon_data)
except TeambuilderReadException as e:
await ctx.send(f"Couldn't validate your team.\n{e}")
return
except Exception:
await ctx.send("Couldn't properly parse your team. Make sure you follow the format provided by Showdown's Team Builder. An error has been logged to console to debug this issue.")
self.log.exception("Failed to read a teambuilder team string.")
return
await self.config.member(ctx.author).party.set(party)
embed = discord.Embed(
title="Your new party",
color=await ctx.embed_color(),
)
embed.set_thumbnail(url=ctx.author.display_avatar.url)
await self.gen_party_embed(ctx, party, embed)
await ctx.send(embed=embed)
@party.command(name="pokecord", hidden=True)
async def party_pokecord(self, ctx, *ids: int):
"""Create a party of pokemon imported from Pokecord."""
pass
@party.command(name="list", aliases=["view"])
async def party_list(self, ctx):
"""View the pokemon currently in your party."""
party = await self.config.member(ctx.author).party()
if len(party) == 0:
await ctx.send(f"You haven't setup your party yet!\nSet one with `{ctx.prefix}pokemonduel party set`.")
return
embed = discord.Embed(
title=f"{ctx.author.display_name}'s Party",
color=await ctx.embed_color(),
)
embed.set_thumbnail(url=ctx.author.display_avatar.url)
await self.gen_party_embed(ctx, party, embed)
await ctx.send(embed=embed)
@staticmethod
async def gen_party_embed(ctx, party, embed):
"""Adds fields to the provided `embed` that are rendered descriptors of the pokemon in the provided `party`."""
for idx, pokemon in enumerate(party):
pokname = pokemon["pokname"]
poknick = pokemon["poknick"]
gender = pokemon["gender"]
if gender == "-m":
gender = "Male"
elif gender == "-f":
gender = "Female"
elif gender == "-x":
gender = "Genderless"
moves = pokemon["moves"]
moves = "|".join([f"`{x}`" for x in moves])
ability_index = pokemon["ability_index"]
form_info = await find_one(ctx, "forms", {"identifier": pokname.lower()})
ab_ids = []
for record in await find(ctx, "poke_abilities", {"pokemon_id": form_info["pokemon_id"]}):
ab_ids.append(record["ability_id"])
try:
ab_id = ab_ids[ability_index]
except IndexError:
ab_id = ab_ids[0]
ability = await find_one(ctx, "abilities", {"id": ab_id})
ability = ability["identifier"]
hitem = pokemon["hitem"]
nature = pokemon["nature"].lower()
happiness = pokemon["happiness"]
hpiv = pokemon["hpiv"]
atkiv = pokemon["atkiv"]
defiv = pokemon["defiv"]
spatkiv = pokemon["spatkiv"]
spdefiv = pokemon["spdefiv"]
speediv = pokemon["speediv"]
hpev = pokemon["hpev"]
atkev = pokemon["atkev"]
defev = pokemon["defev"]
spatkev = pokemon["spatkev"]
spdefev = pokemon["spdefev"]
speedev = pokemon["speedev"]
title = f"{gender} {pokname} "
if poknick != "None":
title += f"({poknick}) "
desc = f"{moves}\nAbility `{ability}`"
if hitem != "None":
desc += f" | Holding `{hitem}`"
desc += f"\nNature `{nature}` | Happiness `{happiness}`\n"
desc += "` `|` hp`|`atk`|`def`|`spa`|`spd`|`spe`\n"
desc += f"`IVs:`|`{hpiv:3d}`|`{atkiv:3d}`|`{defiv:3d}`|`{spatkiv:3d}`|`{spdefiv:3d}`|`{speediv:3d}`\n"
desc += f"`EVs:`|`{hpev:3d}`|`{atkev:3d}`|`{defev:3d}`|`{spatkev:3d}`|`{spdefev:3d}`|`{speedev:3d}`\n"
embed.add_field(name=title, value=desc, inline=bool(idx % 2))
@commands.guild_only()
@commands.guildowner()
@commands.group(invoke_without_command=True)
async def pokemonduelset(self, ctx):
"""Config options for pokemon duels."""
await ctx.send_help()
cfg = await self.config.guild(ctx.guild).all()
msg = (
"Game contained to a thread: {useThreads}\n"
).format_map(cfg)
await ctx.send(f"```py\n{msg}```")
@pokemonduelset.command()
async def thread(self, ctx, value: bool=None):
"""
Set if a thread should be created per-game to contain game messages.
Defaults to False.
This value is server specific.
"""
if value is None:
v = await self.config.guild(ctx.guild).useThreads()
if v:
await ctx.send("The game is currently run in a per-game thread.")
else:
await ctx.send("The game is not currently run in a thread.")
else:
await self.config.guild(ctx.guild).useThreads.set(value)
if value:
await ctx.send("The game will now be run in a per-game thread.")
else:
await ctx.send("The game will not be run in a thread.")