diff --git a/miyu_bot/commands/cogs/card.py b/miyu_bot/commands/cogs/card.py index e3fe803..8fa83cb 100644 --- a/miyu_bot/commands/cogs/card.py +++ b/miyu_bot/commands/cogs/card.py @@ -1,16 +1,23 @@ import asyncio +import enum import logging import discord from d4dj_utils.master.card_master import CardMaster +from d4dj_utils.master.event_master import EventMaster +from d4dj_utils.master.event_specific_bonus_master import EventSpecificBonusMaster +from d4dj_utils.master.skill_master import SkillMaster from discord.ext import commands -from main import masters +from main import masters, asset_manager +from miyu_bot.commands.common.argument_parsing import ParsedArguments, parse_arguments, ArgumentError from miyu_bot.commands.common.emoji import rarity_emoji_ids, attribute_emoji_ids_by_attribute_id, \ unit_emoji_ids_by_unit_id, parameter_bonus_emoji_ids_by_parameter_id +from miyu_bot.commands.common.event import get_latest_event from miyu_bot.commands.common.formatting import format_info from miyu_bot.commands.common.master_asset_manager import hash_master -from miyu_bot.commands.common.reaction_message import run_tabbed_message +from miyu_bot.commands.common.name_aliases import characters_by_name, attributes_by_name, units_by_name +from miyu_bot.commands.common.reaction_message import run_tabbed_message, run_reaction_message, run_paged_message class Card(commands.Cog): @@ -28,14 +35,129 @@ class Card(commands.Cog): help='!card secretcage') async def card(self, ctx: commands.Context, *, arg: commands.clean_content): self.logger.info(f'Searching for card "{arg}".') - card = masters.cards.get(arg, ctx) - if card.rarity_id >= 3: - embeds = [self.get_card_embed(card, 0), self.get_card_embed(card, 1)] + try: + arguments = parse_arguments(arg) + cards = self.get_cards(ctx, arguments) + except ArgumentError as e: + await ctx.send(str(e)) + return + + if not cards: + await ctx.send(f'No results for card "{arg}"') + return + + if len(cards) == 1 or arguments.text(): + embeds = self.get_card_embeds(cards[0]) asyncio.ensure_future(run_tabbed_message(ctx, self.rarity_emoji, embeds, starting_index=1)) else: - embeds = [self.get_card_embed(card, 0)] - asyncio.ensure_future(run_tabbed_message(ctx, self.rarity_emoji[:1], embeds, starting_index=0)) + message = await ctx.send(embed=self.get_card_embeds(cards[0])[1]) + + emojis = self.rarity_emoji + ['◀', '▶'] + + index = 0 + limit_break = 1 + + async def callback(emoji, _ctx, _message): + nonlocal index + nonlocal limit_break + try: + emoji_index = emojis.index(emoji) + if emoji_index == 0: + limit_break = 0 + elif emoji_index == 1: + limit_break = 1 + elif emoji_index == 2: + index -= 1 + else: + index += 1 + + index = min(len(cards) - 1, max(0, index)) + + await message.edit(embed=self.get_card_embeds(cards[index])[limit_break]) + except ValueError: + pass + + asyncio.ensure_future(run_reaction_message(ctx, message, emojis, callback)) + + def get_card_embeds(self, card): + if card.rarity_id >= 3: + return [self.get_card_embed(card, 0), self.get_card_embed(card, 1)] + else: + return [self.get_card_embed(card, 0)] * 2 # no actual awakened art for 1/2* cards + + @commands.command(name='cards', + aliases=[], + description='Lists cards matching the given search terms.', + help='!cards') + async def cards(self, ctx: commands.Context, *, arg: commands.clean_content = ''): + self.logger.info(f'Searching for cards "{arg}".') + + try: + arguments = parse_arguments(arg) + cards = self.get_cards(ctx, arguments) + sort, sort_op = arguments.single('sort', None, + allowed_operators=['<', '>', '='], converter=card_attribute_aliases) + display, _ = arguments.single(['display', 'disp'], sort or CardAttribute.Power, allowed_operators=['='], + converter=card_attribute_aliases) + except ArgumentError as e: + await ctx.send(str(e)) + return + + listing = [] + for card in cards: + display_prefix = display.get_formatted_from_card(card) + if display_prefix: + listing.append( + f'{display_prefix} : {self.format_card_name(card)}') + else: + listing.append(self.format_card_name(card)) + + embed = discord.Embed(title=f'Card Search "{arg}"' if arg else 'Cards') + asyncio.ensure_future(run_paged_message(ctx, embed, listing)) + + def get_cards(self, ctx, arguments: ParsedArguments): + sort, sort_op = arguments.single('sort', None, + allowed_operators=['<', '>', '='], converter=card_attribute_aliases) + reverse_sort = sort_op == '<' or arguments.tag('reverse') + # Not used, but here because it's a valid argument before running require_all_arguments_used. + display, _ = arguments.single(['display', 'disp'], sort, allowed_operators=['='], + converter=card_attribute_aliases) + character = {characters_by_name[c].id for c in arguments.words(characters_by_name.keys())} + unit = {units_by_name[a].id for a in arguments.words(units_by_name.keys())} + rarity = {int(r[0]) for r in arguments.words(['4*', '3*', '2*', '1*', r'4\*', r'3\*', r'2\*', r'1\*'])} + attribute = {attributes_by_name[a].id for a in arguments.words(attributes_by_name.keys())} + + event_bonus = bool(arguments.tags(['event', 'eventbonus', 'event_bonus'])) + + if event_bonus: + latest_event = get_latest_event(ctx) + bonus: EventSpecificBonusMaster = latest_event.bonus + character.update(bonus.character_ids) + attribute.add(bonus.attribute_id) + if not arguments.has_named('sort'): + sort = CardAttribute.Date + + arguments.require_all_arguments_used() + + cards = masters.cards.get_sorted(arguments.text(), ctx) + if not (arguments.text() and sort is None): + sort = sort or CardAttribute.Power + cards = sorted(cards, key=lambda c: (sort.get_sort_key_from_card(c), c.max_power_with_limit_break)) + if sort in [CardAttribute.Power, CardAttribute.Date]: + cards = cards[::-1] + if reverse_sort: + cards = cards[::-1] + if character: + cards = [card for card in cards if card.character.id in character] + if unit: + cards = [card for card in cards if card.character.unit.id in unit] + if rarity: + cards = [card for card in cards if card.rarity_id in rarity] + if attribute: + cards = [card for card in cards if card.attribute.id in attribute] + + return cards def get_card_embed(self, card: CardMaster, limit_break): embed = discord.Embed(title=self.format_card_name(card)) @@ -53,7 +175,7 @@ class Card(commands.Cog): value=format_info({ 'Rarity': f'{card.rarity_id}★', 'Character': f'{card.character.full_name_english}', - 'Attribute': f'{self.bot.get_emoji(attribute_emoji_ids_by_attribute_id[card.attribute_id])} {card.attribute.en_name}', + 'Attribute': f'{self.bot.get_emoji(attribute_emoji_ids_by_attribute_id[card.attribute_id])} {card.attribute.en_name.capitalize()}', 'Unit': f'{self.bot.get_emoji(unit_emoji_ids_by_unit_id[card.character.unit_id])} {card.character.unit.name}', 'Release Date': f'{card.start_datetime}', }), @@ -65,7 +187,18 @@ class Card(commands.Cog): f'{self.bot.get_emoji(parameter_bonus_emoji_ids_by_parameter_id[2])} Technique': f'{"{:,}".format(card.max_parameters_with_limit_break[1])}', f'{self.bot.get_emoji(parameter_bonus_emoji_ids_by_parameter_id[3])} Physical': f'{"{:,}".format(card.max_parameters_with_limit_break[2])}', }), - inline=False) + inline=True) + skill: SkillMaster = card.skill + embed.add_field(name='Skill', + value=format_info({ + 'Name': card.skill_name, + 'Duration': f'{skill.min_seconds}-{skill.max_seconds}s', + 'Score Up': f'{skill.score_up_rate}%', + 'Heal': (f'{skill.min_recovery_value}-{skill.max_recovery_value}' + if skill.min_recovery_value != skill.max_recovery_value + else str(skill.min_recovery_value)) + }), + inline=True) return embed @@ -73,5 +206,37 @@ class Card(commands.Cog): return f'{card.rarity_id}★ {card.name} {card.character.full_name_english}' +class CardAttribute(enum.Enum): + Name = enum.auto() + Id = enum.auto() + Power = enum.auto() + Date = enum.auto() + + def get_sort_key_from_card(self, card: CardMaster): + return { + self.Name: card.name, + self.Id: card.id, + self.Power: card.max_power_with_limit_break, + self.Date: card.start_datetime, + }[self] + + def get_formatted_from_card(self, card: CardMaster): + return { + self.Name: None, + self.Id: str(card.id).zfill(9), + self.Power: str(card.max_power_with_limit_break).rjust(5), + self.Date: str(card.start_datetime.date()), + }[self] + + +card_attribute_aliases = { + 'name': CardAttribute.Name, + 'id': CardAttribute.Id, + 'power': CardAttribute.Power, + 'stats': CardAttribute.Power, + 'date': CardAttribute.Date, +} + + def setup(bot): bot.add_cog(Card(bot)) diff --git a/miyu_bot/commands/cogs/event.py b/miyu_bot/commands/cogs/event.py index edde5cb..3ee7c82 100644 --- a/miyu_bot/commands/cogs/event.py +++ b/miyu_bot/commands/cogs/event.py @@ -14,6 +14,7 @@ from main import asset_manager, masters from miyu_bot.commands.common.emoji import attribute_emoji_ids_by_attribute_id, unit_emoji_ids_by_unit_id, \ parameter_bonus_emoji_ids_by_parameter_id, \ event_point_emoji_id +from miyu_bot.commands.common.event import get_latest_event from miyu_bot.commands.common.formatting import format_info from miyu_bot.commands.common.fuzzy_matching import romanize from miyu_bot.commands.common.master_asset_manager import MasterFilter, hash_master @@ -37,14 +38,14 @@ class Event(commands.Cog): # Allows relative id searches like `!event +1` for next event or `!event -2` for the event before last event if arg[0] in ['-', '+']: try: - latest = self.get_latest_event(ctx) + latest = get_latest_event(ctx) event = masters.events.get(str(latest.id + int(arg)), ctx) except ValueError: event = masters.events.get(arg, ctx) else: event = masters.events.get(arg, ctx) else: - event = self.get_latest_event(ctx) + event = get_latest_event(ctx) if not event: msg = f'Failed to find event "{arg}".' @@ -150,7 +151,7 @@ class Event(commands.Cog): description='Displays the time left in the current event', help='!timeleft') async def time_left(self, ctx: commands.Context): - latest = self.get_latest_event(ctx) + latest = get_latest_event(ctx) state = latest.state() @@ -204,19 +205,6 @@ class Event(commands.Cog): await ctx.send(files=[logo], embed=embed) - def get_latest_event(self, ctx: commands.Context) -> EventMaster: - """Returns the oldest event that has not ended or the newest event otherwise.""" - try: - # NY event overlapped with previous event - return min((v for v in masters.events.values(ctx) if v.state() == EventState.Open), - key=lambda e: e.start_datetime) - except ValueError: - try: - return min((v for v in masters.events.values(ctx) if v.state() < EventState.Ended), - key=lambda e: e.start_datetime) - except ValueError: - return max(masters.events.values(ctx), key=lambda v: v.start_datetime) - @commands.command(name='t20', aliases=['top20', 'top_20'], description='Displays the top 20 in the main leaderboard', @@ -226,7 +214,7 @@ class Event(commands.Cog): async with session.get('http://www.projectdivar.com/eventdata/t20') as resp: leaderboard = await resp.json(encoding='utf-8') - latest = self.get_latest_event(ctx) + latest = get_latest_event(ctx) logo = discord.File(latest.logo_path, filename='logo.png') embed = discord.Embed(title=f'{latest.name} t20').set_thumbnail(url=f'attachment://logo.png') max_points_digits = len(str(leaderboard[0]['points'])) diff --git a/miyu_bot/commands/cogs/music.py b/miyu_bot/commands/cogs/music.py index 3fb8276..ca2abbb 100644 --- a/miyu_bot/commands/cogs/music.py +++ b/miyu_bot/commands/cogs/music.py @@ -56,7 +56,7 @@ class Music(commands.Cog): song = masters.music.get(arg, ctx) if not song: - msg = f'Failed to find song "{arg}".' + msg = f'No results for song "{arg}".' await ctx.send(msg) self.logger.info(msg) return @@ -171,7 +171,6 @@ class Music(commands.Cog): async def songs(self, ctx: commands.Context, *, arg: commands.clean_content = ''): self.logger.info(f'Searching for songs "{arg}".' if arg else 'Listing songs.') arguments = parse_arguments(arg) - songs = masters.music.get_sorted(arguments.text_argument, ctx) try: sort, sort_op = arguments.single('sort', MusicAttribute.DefaultOrder, @@ -203,6 +202,9 @@ class Music(commands.Cog): difficulty = arguments.repeatable(['difficulty', 'diff', 'level'], is_list=True, converter=difficulty_converter) + + songs = masters.music.get_sorted(arguments.text(), ctx) + arguments.require_all_arguments_used() except ArgumentError as e: await ctx.send(str(e)) @@ -231,11 +233,13 @@ class Music(commands.Cog): songs = [song for song in songs if song.unit.id in allowed_unit_ids] if not (arguments.text_argument and sort == MusicAttribute.DefaultOrder): - songs = sorted(songs, key=lambda s: sort.get_from_music(s)) + songs = sorted(songs, key=lambda s: sort.get_sort_key_from_music(s)) if sort == MusicAttribute.DefaultOrder and songs and songs[0].id == 1: songs = [*songs[1:], songs[0]] + if sort in [MusicAttribute.Level, MusicAttribute.Date]: + songs = songs[::-1] if reverse_sort: - songs = reversed(songs) + songs = songs[::-1] listing = [] for song in songs: @@ -394,7 +398,7 @@ class MusicAttribute(enum.Enum): Duration = enum.auto() Date = enum.auto() - def get_from_music(self, music: MusicMaster): + def get_sort_key_from_music(self, music: MusicMaster): return { self.DefaultOrder: -music.default_order, self.Name: music.name, diff --git a/miyu_bot/commands/common/argument_parsing.py b/miyu_bot/commands/common/argument_parsing.py index 03bcf7a..9368ad0 100644 --- a/miyu_bot/commands/common/argument_parsing.py +++ b/miyu_bot/commands/common/argument_parsing.py @@ -107,6 +107,9 @@ class ParsedArguments: self.used_tags.add(alias) return results + def has_named(self, name: str): + return name in self.named_arguments + def single(self, names: Union[List[str], str], default: Any = None, allowed_operators: Optional[Container] = None, is_list=False, numeric=False, converter: Union[dict, Callable] = lambda n: n): if allowed_operators is None: diff --git a/miyu_bot/commands/common/event.py b/miyu_bot/commands/common/event.py new file mode 100644 index 0000000..9e889f9 --- /dev/null +++ b/miyu_bot/commands/common/event.py @@ -0,0 +1,19 @@ +from d4dj_utils.master.event_master import EventMaster, EventState +from discord.ext import commands + +from main import masters + + +def get_latest_event(ctx: commands.Context) -> EventMaster: + """Returns the oldest event that has not ended or the newest event otherwise.""" + try: + # NY event overlapped with previous event + return min((v for v in masters.events.values(ctx) if v.state() == EventState.Open), + key=lambda e: e.start_datetime) + except ValueError: + try: + return min((v for v in masters.events.values(ctx) if v.state() < EventState.Ended), + key=lambda e: e.start_datetime) + except ValueError: + return max(masters.events.values(ctx), key=lambda v: v.start_datetime) + diff --git a/miyu_bot/commands/common/master_asset_manager.py b/miyu_bot/commands/common/master_asset_manager.py index ca0c396..be276f0 100644 --- a/miyu_bot/commands/common/master_asset_manager.py +++ b/miyu_bot/commands/common/master_asset_manager.py @@ -30,7 +30,7 @@ class MasterFilterManager: ) self.cards = MasterFilter( self.manager.card_master, - naming_function=lambda c: c.name, + naming_function=lambda c: f'{c.name} {c.character.first_name_english}', filter_function=lambda c: c.is_released, ) diff --git a/miyu_bot/commands/common/name_aliases.py b/miyu_bot/commands/common/name_aliases.py new file mode 100644 index 0000000..0f2f423 --- /dev/null +++ b/miyu_bot/commands/common/name_aliases.py @@ -0,0 +1,11 @@ +from main import asset_manager + +characters_by_name = {} +for character in asset_manager.character_master.values(): + for name in character.full_name_english.split(): + characters_by_name[name.lower()] = character + +attributes_by_name = {attribute.en_name: attribute for attribute in asset_manager.attribute_master.values()} + +units_by_name = {unit.name: unit for unit in asset_manager.unit_master.values()} +units_by_name['rondo'] = units_by_name['燐舞曲']