diff --git a/werewolf/builder.py b/werewolf/builder.py index 0be61d9..0d2eee5 100644 --- a/werewolf/builder.py +++ b/werewolf/builder.py @@ -1,25 +1,97 @@ -from typing import List +import bisect +from collections import defaultdict +from random import choice import discord +from redbot.core import RedContext # Import all roles here -from werewolf.role import Role from werewolf.roles.seer import Seer from werewolf.roles.vanillawerewolf import VanillaWerewolf from werewolf.roles.villager import Villager +from werewolf.utils.menus import menu, prev_page, next_page, close_menu # All roles in this list for iterating -role_list = [Villager, VanillaWerewolf] + +ROLE_LIST = sorted([Villager, Seer, VanillaWerewolf], key=lambda x: x.alignment) + +ALIGNMENT_COLORS = [0x008000, 0xff0000, 0xc0c0c0] +TOWN_ROLES = [(idx, role) for idx, role in enumerate(ROLE_LIST) if role.alignment == 1] +WW_ROLES = [(idx, role) for idx, role in enumerate(ROLE_LIST) if role.alignment == 2] +OTHER_ROLES = [(idx, role) for idx, role in enumerate(ROLE_LIST) if role.alignment not in [0, 1]] + +ROLE_PAGES = [] +PAGE_GROUPS = [] + +ROLE_CATEGORIES = { + 1: "Random", 2: "Investigative", 3: "Protective", 4: "Government", + 5: "Killing", 6: "Power (Special night action)", + 11: "Random", 12: "Deception", 15: "Killing", 16: "Support", + 21: "Benign", 22: "Evil", 23: "Killing"} + +CATEGORY_COUNT = [] + + +def role_embed(idx, role, color): + embed = discord.Embed(title="**{}** - {}".format(idx, str(role.__name__)), description=role.game_start_message, + color=color) + embed.add_field(name='Alignment', value=['Town', 'Werewolf', 'Neutral'][role.alignment - 1], inline=True) + embed.add_field(name='Multiples Allowed', value=str(not role.unique), inline=True) + embed.add_field(name='Role Type', value=", ".join(ROLE_CATEGORIES[x] for x in role.category), inline=True) + embed.add_field(name='Random Option', value=str(role.rand_choice), inline=True) + + return embed + + +def setup(): + # Roles + if len(ROLE_PAGES) - 1 not in PAGE_GROUPS: + PAGE_GROUPS.append(len(ROLE_PAGES) - 1) + + last_alignment = ROLE_LIST[0].alignment + for idx, role in enumerate(ROLE_LIST): + if role.alignment != last_alignment and len(ROLE_PAGES) - 1 not in PAGE_GROUPS: + PAGE_GROUPS.append(len(ROLE_PAGES) - 1) + last_alignment = role.alignment + + ROLE_PAGES.append(role_embed(idx, role, ALIGNMENT_COLORS[role.alignment - 1])) + + # Random Town Roles + if len(ROLE_PAGES) - 1 not in PAGE_GROUPS: + PAGE_GROUPS.append(len(ROLE_PAGES) - 1) + for k, v in ROLE_CATEGORIES.items(): + if 0 < k <= 6: + ROLE_PAGES.append(discord.Embed(title="RANDOM Town Role", description="Town {}".format(v), color=0x008000)) + CATEGORY_COUNT.append(k) + + # Random WW Roles + if len(ROLE_PAGES) - 1 not in PAGE_GROUPS: + PAGE_GROUPS.append(len(ROLE_PAGES) - 1) + for k, v in ROLE_CATEGORIES.items(): + if 10 < k <= 16: + ROLE_PAGES.append( + discord.Embed(title="RANDOM Werewolf Role", description="Werewolf {}".format(v), color=0xff0000)) + CATEGORY_COUNT.append(k) + # Random Neutral Roles + if len(ROLE_PAGES) - 1 not in PAGE_GROUPS: + PAGE_GROUPS.append(len(ROLE_PAGES) - 1) + for k, v in ROLE_CATEGORIES.items(): + if 20 < k <= 26: + ROLE_PAGES.append( + discord.Embed(title="RANDOM Neutral Role", description="Neutral {}".format(v), color=0xc0c0c0)) + CATEGORY_COUNT.append(k) + """ Example code: 0 = Villager 1 = VanillaWerewolf -E1 = Random Town -R1 = Random Werewolf -J1 = Benign Neutral +T1 - T6 = Random Town (1: Random, 2: Investigative, 3: Protective, 4: Government, + 5: Killing, 6: Power (Special night action)) +W1, W2, W5, W6 = Random Werewolf +N1 = Benign Neutral -0001-1112E11R112P2 +0001-1112T11W112N2 0,0,0,1,11,12,E1,R1,R1,R1,R2,P2 pre-letter = exact role position @@ -29,27 +101,191 @@ double digit position preempted by `-` async def parse_code(code, game): """Do the magic described above""" - out: List[Role] = [] - decode = code.copy() # for now, pass exact names - for role_id in decode: - print(role_id) - if role_id == "Villager": - role = Villager(game) - elif role_id == "VanillaWerewolf": - role = VanillaWerewolf(game) - elif role_id == "Seer": - role = Seer(game) - else: # Fail to parse - return None - out.append(role) - - return out - - -async def build_game(channel: discord.TextChannel): - await channel.send("Not currently available") - - code = 12345678 - - await channel.send("Your game code is **`{}`**".format(code)) - # Make this embeds + decode = [] + + digits = 1 + built = "" + category = "" + for c in code: + if built == "T" or built == "W" or built == "N": + # Random Towns + category = built + built = "" + digits = 1 + elif built == "-": + digits += 1 + + if len(built) < digits: + built += c + continue + + try: + idx = int(built) + except ValueError: + raise ValueError("Invalid code") + + if category == "": # no randomness yet + decode.append(ROLE_LIST[idx](game)) + else: + options = [] + if category == "T": + options = [role for role in ROLE_LIST if idx in role.category] + elif category == "W": + options = [role for role in ROLE_LIST if 10 + idx in role.category] + elif category == "N": + options = [role for role in ROLE_LIST if 20 + idx in role.category] + pass + + if not options: + raise ValueError("No Match Found") + + decode.append(choice(options)(game)) + + return decode + + +async def encode(roles, rand_roles): + """Convert role list to code""" + out_code = "" + + digit_sort = sorted(role for role in roles if role < 10) + for role in digit_sort: + out_code += str(role) + + digit_sort = sorted(role for role in roles if 10 <= role < 100) + if digit_sort: + out_code += "-" + for role in digit_sort: + out_code += str(role) + # That covers up to 99 roles, add another set here if we breach 100 + + if rand_roles: + # town sort + digit_sort = sorted(role for role in rand_roles if role <= 6) + if digit_sort: + out_code += "T" + for role in digit_sort: + out_code += role + + # werewolf sort + digit_sort = sorted(role for role in rand_roles if 10 < role <= 20) + if digit_sort: + out_code += "W" + for role in digit_sort: + out_code += role + + # neutral sort + digit_sort = sorted(role for role in rand_roles if 20 < role <= 30) + if digit_sort: + out_code += "N" + for role in digit_sort: + out_code += role + + return out_code + + +async def next_group(ctx: RedContext, pages: list, + controls: dict, message: discord.Message, page: int, + timeout: float, emoji: str): + perms = message.channel.permissions_for(ctx.guild.me) + if perms.manage_messages: # Can manage messages, so remove react + try: + await message.remove_reaction(emoji, ctx.author) + except discord.NotFound: + pass + page = bisect.bisect_right(PAGE_GROUPS, page) + + if page == len(PAGE_GROUPS): + page = PAGE_GROUPS[0] + else: + page = PAGE_GROUPS[page] + + return await menu(ctx, pages, controls, message=message, + page=page, timeout=timeout) + + +async def prev_group(ctx: RedContext, pages: list, + controls: dict, message: discord.Message, page: int, + timeout: float, emoji: str): + perms = message.channel.permissions_for(ctx.guild.me) + if perms.manage_messages: # Can manage messages, so remove react + try: + await message.remove_reaction(emoji, ctx.author) + except discord.NotFound: + pass + page = PAGE_GROUPS[bisect.bisect_left(PAGE_GROUPS, page) - 1] + + return await menu(ctx, pages, controls, message=message, + page=page, timeout=timeout) + + +def say_role_list(code_list): + roles = [ROLE_LIST[idx] for idx in code_list] + embed = discord.Embed(title="Currently selected roles") + role_dict = defaultdict(int) + for role in roles: + role_dict[str(role.__name__)] += 1 + + for k, v in role_dict.items(): + embed.add_field(name=k, value="Count: {}".format(v), inline=True) + + return embed + + +class GameBuilder: + + def __init__(self): + self.code = [] + self.rand_roles = [] + setup() + + async def build_game(self, ctx: RedContext): + new_controls = { + '⏪': prev_group, + "⬅": prev_page, + '☑': self.select_page, + "➡": next_page, + '⏩': next_group, + '📇': self.list_roles, + "❌": close_menu + } + + await ctx.send("Browse through roles and add the ones you want using the check mark") + + await menu(ctx, ROLE_PAGES, new_controls, timeout=60) + + out = await encode(self.code, self.rand_roles) + return out + + async def list_roles(self, ctx: RedContext, pages: list, + controls: dict, message: discord.Message, page: int, + timeout: float, emoji: str): + perms = message.channel.permissions_for(ctx.guild.me) + if perms.manage_messages: # Can manage messages, so remove react + try: + await message.remove_reaction(emoji, ctx.author) + except discord.NotFound: + pass + + await ctx.send(embed=say_role_list(self.code)) + + return await menu(ctx, pages, controls, message=message, + page=page, timeout=timeout) + + async def select_page(self, ctx: RedContext, pages: list, + controls: dict, message: discord.Message, page: int, + timeout: float, emoji: str): + perms = message.channel.permissions_for(ctx.guild.me) + if perms.manage_messages: # Can manage messages, so remove react + try: + await message.remove_reaction(emoji, ctx.author) + except discord.NotFound: + pass + + if page >= len(ROLE_LIST): + self.rand_roles.append(CATEGORY_COUNT[len(ROLE_LIST) - page]) + else: + self.code.append(page) + + return await menu(ctx, pages, controls, message=message, + page=page, timeout=timeout) diff --git a/werewolf/game.py b/werewolf/game.py index be7d0e1..ec3d0ff 100644 --- a/werewolf/game.py +++ b/werewolf/game.py @@ -3,20 +3,16 @@ import random from typing import List import discord +from redbot.core import RedContext from werewolf.builder import parse_code from werewolf.player import Player -from werewolf.role import Role class Game: """ Base class to run a single game of Werewolf """ - players: List[Player] - roles: List[Role] - channel_category: discord.CategoryChannel - village_channel: discord.TextChannel default_secret_channel = { "channel": None, @@ -33,11 +29,11 @@ class Game: def __init__(self, guild: discord.Guild, role: discord.Role, game_code=None): self.guild = guild - self.game_code = ["Seer", "VanillaWerewolf", "Villager"] + self.game_code = game_code self.game_role = role - self.roles = [] - self.players = [] + self.roles = [] # List[Role] + self.players = [] # List[Player] self.day_vote = {} # author: target self.vote_totals = {} # id: total_votes @@ -51,8 +47,8 @@ class Game: self.day_count = 0 self.ongoing_vote = False - self.channel_category = None - self.village_channel = None + self.channel_category = None # discord.CategoryChannel + self.village_channel = None # discord.TextChannel self.p_channels = {} # uses default_secret_channel self.vote_groups = {} # ID : VoteGroup() @@ -76,7 +72,7 @@ class Game: for c_data in self.p_channels.values(): asyncio.ensure_future(c_data["channel"].delete("Werewolf game-over")) - async def setup(self, ctx): + async def setup(self, ctx: RedContext): """ Runs the initial setup @@ -87,10 +83,13 @@ class Game: 4. Start game """ if self.game_code: - await self.get_roles() + await self.get_roles(ctx) if len(self.players) != len(self.roles): - await ctx.send("Player count does not match role count, cannot start") + await ctx.send("Player count does not match role count, cannot start\n" + "Currently **{} / {}**\n" + "Use `{}ww code` to pick a new game" + "".format(len(self.players), len(self.roles), ctx.prefix)) self.roles = [] return False @@ -256,7 +255,7 @@ class Game: reaction_list = message.reactions up_votes = sum(p for p in reaction_list if p.emoji == "👍" and not p.me) - down_votes = sum(p for p in reaction_list if p.emoji == "👎" and not p.me ) + down_votes = sum(p for p in reaction_list if p.emoji == "👎" and not p.me) if down_votes > up_votes: embed = discord.Embed(title="Vote Results", color=0xff0000) @@ -616,14 +615,21 @@ class Game: async def get_day_target(self, target_id, source=None): return self.players[target_id] # ToDo check source - async def get_roles(self, game_code=None): + async def get_roles(self, ctx, game_code=None): if game_code is not None: self.game_code = game_code if self.game_code is None: return False - self.roles = await parse_code(self.game_code, self) + try: + self.roles = await parse_code(self.game_code, self) + except ValueError("Invalid Code"): + await ctx.send("Invalid Code") + return False + except ValueError("No Match Found"): + await ctx.send("Code contains unknown role") + return False if not self.roles: return False diff --git a/werewolf/utils/menus.py b/werewolf/utils/menus.py new file mode 100644 index 0000000..35b4fbd --- /dev/null +++ b/werewolf/utils/menus.py @@ -0,0 +1,134 @@ +import asyncio + +import discord +from redbot.core import RedContext + + +async def menu(ctx: RedContext, pages: list, + controls: dict, + message: discord.Message = None, page: int = 0, + timeout: float = 30.0): + """ + An emoji-based menu + + .. note:: All pages should be of the same type + + .. note:: All functions for handling what a particular emoji does + should be coroutines (i.e. :code:`async def`). Additionally, + they must take all of the parameters of this function, in + addition to a string representing the emoji reacted with. + This parameter should be the last one, and none of the + parameters in the handling functions are optional + + Parameters + ---------- + ctx: RedContext + The command context + pages: `list` of `str` or `discord.Embed` + The pages of the menu. + controls: dict + A mapping of emoji to the function which handles the action for the + emoji. + message: discord.Message + The message representing the menu. Usually :code:`None` when first opening + the menu + page: int + The current page number of the menu + timeout: float + The time (in seconds) to wait for a reaction + + Raises + ------ + RuntimeError + If either of the notes above are violated + """ + if not all(isinstance(x, discord.Embed) for x in pages) and \ + not all(isinstance(x, str) for x in pages): + raise RuntimeError("All pages must be of the same type") + for key, value in controls.items(): + if not asyncio.iscoroutinefunction(value): + raise RuntimeError("Function must be a coroutine") + current_page = pages[page] + + if not message: + if isinstance(current_page, discord.Embed): + message = await ctx.send(embed=current_page) + else: + message = await ctx.send(current_page) + for key in controls.keys(): + await message.add_reaction(key) + else: + if isinstance(current_page, discord.Embed): + await message.edit(embed=current_page) + else: + await message.edit(content=current_page) + + def react_check(r, u): + return u == ctx.author and str(r.emoji) in controls.keys() + + try: + react, user = await ctx.bot.wait_for( + "reaction_add", + check=react_check, + timeout=timeout + ) + except asyncio.TimeoutError: + try: + await message.clear_reactions() + except discord.Forbidden: # cannot remove all reactions + for key in controls.keys(): + await message.remove_reaction(key, ctx.bot.user) + return None + + return await controls[react.emoji](ctx, pages, controls, + message, page, + timeout, react.emoji) + + +async def next_page(ctx: RedContext, pages: list, + controls: dict, message: discord.Message, page: int, + timeout: float, emoji: str): + perms = message.channel.permissions_for(ctx.guild.me) + if perms.manage_messages: # Can manage messages, so remove react + try: + await message.remove_reaction(emoji, ctx.author) + except discord.NotFound: + pass + if page == len(pages) - 1: + next_page = 0 # Loop around to the first item + else: + next_page = page + 1 + return await menu(ctx, pages, controls, message=message, + page=next_page, timeout=timeout) + + +async def prev_page(ctx: RedContext, pages: list, + controls: dict, message: discord.Message, page: int, + timeout: float, emoji: str): + perms = message.channel.permissions_for(ctx.guild.me) + if perms.manage_messages: # Can manage messages, so remove react + try: + await message.remove_reaction(emoji, ctx.author) + except discord.NotFound: + pass + if page == 0: + page = len(pages) - 1 # Loop around to the last item + else: + page = page - 1 + return await menu(ctx, pages, controls, message=message, + page=page, timeout=timeout) + + +async def close_menu(ctx: RedContext, pages: list, + controls: dict, message: discord.Message, page: int, + timeout: float, emoji: str): + if message: + await message.delete() + return None + + +DEFAULT_CONTROLS = { + "➡": next_page, + "⬅": prev_page, + "❌": close_menu, +} diff --git a/werewolf/werewolf.py b/werewolf/werewolf.py index 4e31746..31bb88d 100644 --- a/werewolf/werewolf.py +++ b/werewolf/werewolf.py @@ -1,11 +1,10 @@ -from typing import Dict - import discord from discord.ext import commands from redbot.core import Config from redbot.core import RedContext from redbot.core.bot import Red +from werewolf.builder import GameBuilder from werewolf.game import Game @@ -13,7 +12,6 @@ class Werewolf: """ Base to host werewolf on a guild """ - games: Dict[int, Game] def __init__(self, bot: Red): self.bot = bot @@ -33,6 +31,16 @@ class Werewolf: for game in self.games.values(): del game + @commands.command() + async def buildgame(self, ctx): + gb = GameBuilder() + code = await gb.build_game(ctx) + + if code is not None: + await ctx.send("Your game code is **{}**".format(code)) + else: + await ctx.send("No code generated") + @commands.group() async def wwset(self, ctx: RedContext): """