diff --git a/werewolf/builder.py b/werewolf/builder.py index d14b25e..4a9da6a 100644 --- a/werewolf/builder.py +++ b/werewolf/builder.py @@ -1,21 +1,94 @@ +import bisect +from collections import defaultdict +from random import choice + import discord +from discord.ext import commands + # Import all roles here from werewolf.roles.seer import Seer from werewolf.roles.vanillawerewolf import VanillaWerewolf from werewolf.roles.villager import Villager +from redbot.core.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 = [0] + +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 + 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 @@ -23,29 +96,225 @@ double digit position preempted by `-` """ -async def parse_code(code): +async def parse_code(code, game): """Do the magic described above""" - out = [] - decode = code.copy() # for now, pass exact names - for role_id in decode: - print(role_id) - if role_id == "Villager": - role = Villager - elif role_id == "VanillaWerewolf": - role = VanillaWerewolf - elif role_id == "Seer": - role = Seer - 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 IndexError("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 += str(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 += str(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 += str(role) + + return out_code + + +async def next_group(ctx: commands.Context, 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: commands.Context, 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 role_from_alignment(alignment): + return [role_embed(idx, role, ALIGNMENT_COLORS[role.alignment - 1]) + for idx, role in enumerate(ROLE_LIST) if alignment == role.alignment] + + +def role_from_category(category): + return [role_embed(idx, role, ALIGNMENT_COLORS[role.alignment - 1]) + for idx, role in enumerate(ROLE_LIST) if category in role.category] + + +def role_from_id(idx): + try: + role = ROLE_LIST[idx] + except IndexError: + return None + + return role_embed(idx, role, ALIGNMENT_COLORS[role.alignment - 1]) + + +def role_from_name(name: str): + return [role_embed(idx, role, ALIGNMENT_COLORS[role.alignment - 1]) + for idx, role in enumerate(ROLE_LIST) if name in role.__name__] + + +def say_role_list(code_list, rand_roles): + 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 role in rand_roles: + if 0 < role <= 6: + role_dict["Town {}".format(ROLE_CATEGORIES[role])] += 1 + if 10 < role <= 16: + role_dict["Werewolf {}".format(ROLE_CATEGORIES[role])] += 1 + if 20 < role <= 26: + role_dict["Neutral {}".format(ROLE_CATEGORIES[role])] += 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: commands.Context): + 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: commands.Context, 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, self.rand_roles)) + + return await menu(ctx, pages, controls, message=message, + page=page, timeout=timeout) + + async def select_page(self, ctx: commands.Context, 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[page-len(ROLE_LIST)]) + 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 3a559ca..1a6641f 100644 --- a/werewolf/game.py +++ b/werewolf/game.py @@ -2,6 +2,7 @@ import asyncio import random import discord +from discord.ext import commands from werewolf.builder import parse_code from werewolf.player import Player @@ -25,19 +26,14 @@ class Game: day_vote_count = 3 - # def __new__(cls, guild, game_code): - # game_code = ["VanillaWerewolf", "Villager", "Villager"] - # - # return super().__new__(cls, guild, game_code) - - def __init__(self, guild, role, game_code): + def __init__(self, guild: discord.Guild, role: discord.Role = None, + category: discord.CategoryChannel = None, village: discord.TextChannel = None, + log_channel: discord.TextChannel = None, game_code=None): self.guild = guild - self.game_code = ["VanillaWerewolf"] - self.game_role = role + self.game_code = game_code - self.roles = [] - - self.players = [] + self.roles = [] # List[Role] + self.players = [] # List[Player] self.day_vote = {} # author: target self.vote_totals = {} # id: total_votes @@ -49,9 +45,15 @@ class Game: self.day_time = False self.day_count = 0 + self.ongoing_vote = False + + self.game_role = role # discord.Role + self.channel_category = category # discord.CategoryChannel + self.village_channel = village # discord.TextChannel + self.log_channel = log_channel - self.channel_category = None - self.village_channel = None + self.to_delete = set() + self.save_perms = {} self.p_channels = {} # uses default_secret_channel self.vote_groups = {} # ID : VoteGroup() @@ -60,22 +62,22 @@ class Game: self.loop = asyncio.get_event_loop() - def __del__(self): - """ - Cleanup channels as necessary - :return: - """ - - print("Delete is called") - - self.game_over = True - if self.village_channel: - asyncio.ensure_future(self.village_channel.delete("Werewolf game-over")) - - for c_data in self.p_channels.values(): - asyncio.ensure_future(c_data["channel"].delete("Werewolf game-over")) + # def __del__(self): + # """ + # Cleanup channels as necessary + # :return: + # """ + # + # print("Delete is called") + # + # self.game_over = True + # if self.village_channel: + # asyncio.ensure_future(self.village_channel.delete("Werewolf game-over")) + # + # 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: commands.Context): """ Runs the initial setup @@ -86,17 +88,34 @@ 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 if self.game_role is None: - await ctx.send("Game role not configured, cannot start") - self.roles = [] - return False + try: + self.game_role = await ctx.guild.create_role(name="WW Players", + hoist=True, + mentionable=True, + reason="(BOT) Werewolf game role") + self.to_delete.add(self.game_role) + except (discord.Forbidden, discord.HTTPException): + await ctx.send("Game role not configured and unable to generate one, cannot start") + self.roles = [] + return False + try: + for player in self.players: + await player.member.add_roles(*[self.game_role]) + except discord.Forbidden: + await ctx.send( + "Unable to add role **{}**\nBot is missing `manage_roles` permissions".format(self.game_role.name)) + return False await self.assign_roles() @@ -104,21 +123,55 @@ class Game: overwrite = { self.guild.default_role: discord.PermissionOverwrite(read_messages=True, send_messages=False, add_reactions=False), - self.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True, add_reactions=True), + self.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True, add_reactions=True, + manage_messages=True, manage_channels=True, + manage_roles=True), self.game_role: discord.PermissionOverwrite(read_messages=True, send_messages=True) } + if self.channel_category is None: + self.channel_category = await self.guild.create_category("Werewolf Game", + overwrites=overwrite, + reason="(BOT) New game of werewolf") + else: # No need to modify categories + pass + # await self.channel_category.edit(name="🔴 Werewolf Game (ACTIVE)", reason="(BOT) New game of werewolf") + # for target, ow in overwrite.items(): + # await self.channel_category.set_permissions(target=target, + # overwrite=ow, + # reason="(BOT) New game of werewolf") + if self.village_channel is None: + try: + self.village_channel = await self.guild.create_text_channel("🔵Werewolf", + overwrites=overwrite, + reason="(BOT) New game of werewolf", + category=self.channel_category) + except discord.Forbidden: + await ctx.send("Unable to create Game Channel and none was provided\n" + "Grant Bot appropriate permissions or assign a game_channel") + return False + else: + self.save_perms[self.village_channel] = self.village_channel.overwrites + try: + await self.village_channel.edit(name="🔵Werewolf", + category=self.channel_category, + reason="(BOT) New game of werewolf") + except discord.Forbidden as e: + print("Unable to rename Game Channel") + print(e) + await ctx.send("Unable to rename Game Channel, ignoring") - self.channel_category = await self.guild.create_category("ww-game", overwrites=overwrite, reason="New game of " - "werewolf") - - # for player in self.players: - # overwrite[player.member] = discord.PermissionOverwrite(read_messages=True) - - self.village_channel = await self.guild.create_text_channel("Village Square", - overwrites=overwrite, - reason="New game of werewolf", - category=self.channel_category) - + try: + for target, ow in overwrite.items(): + curr = self.village_channel.overwrites_for(target) + curr.update(**{perm: value for perm, value in ow}) + await self.village_channel.set_permissions(target=target, + overwrite=curr, + reason="(BOT) New game of werewolf") + except discord.Forbidden: + await ctx.send("Unable to edit Game Channel permissions\n" + "Grant Bot appropriate permissions to manage permissions") + return + self.started = True # Assuming everything worked so far print("Pre at_game_start") await self._at_game_start() # This will queue channels and votegroups to be made @@ -127,7 +180,9 @@ class Game: print("Channel id: " + channel_id) overwrite = { self.guild.default_role: discord.PermissionOverwrite(read_messages=False), - self.guild.me: discord.PermissionOverwrite(read_messages=True) + self.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True, add_reactions=True, + manage_messages=True, manage_channels=True, + manage_roles=True) } for player in self.p_channels[channel_id]["players"]: @@ -135,7 +190,7 @@ class Game: channel = await self.guild.create_text_channel(channel_id, overwrites=overwrite, - reason="Ww game secret channel", + reason="(BOT) WW game secret channel", category=self.channel_category) self.p_channels[channel_id]["channel"] = channel @@ -207,13 +262,15 @@ class Game: return self.can_vote = True - await asyncio.sleep(12) # 4 minute days FixMe to 120 later + await asyncio.sleep(24) # 4 minute days FixMe to 120 later if check(): return await self.village_channel.send(embed=discord.Embed(title="**Two minutes of daylight remain...**")) - await asyncio.sleep(12) # 4 minute days FixMe to 120 later + await asyncio.sleep(24) # 4 minute days FixMe to 120 later # Need a loop here to wait for trial to end (can_vote?) + while self.ongoing_vote: + asyncio.sleep(5) if check(): return @@ -226,16 +283,17 @@ class Game: data = {"player": target} await self._notify(2, data) + self.ongoing_vote = True + self.used_votes += 1 - self.can_vote = False - await self.speech_perms(self.village_channel, target.member) + await self.speech_perms(self.village_channel, target.member) # Only target can talk await self.village_channel.send( "**{} will be put to trial and has 30 seconds to defend themselves**".format(target.mention)) await asyncio.sleep(30) - await self.speech_perms(self.village_channel, target.member, undo=True) + await self.speech_perms(self.village_channel, target.member, undo=True) # No one can talk message = await self.village_channel.send( "Everyone will now vote whether to lynch {}\n" @@ -243,42 +301,46 @@ class Game: "*Majority rules, no-lynch on ties, " "vote both or neither to abstain, 15 seconds to vote*".format(target.mention)) - await self.village_channel.add_reaction("👍") - await self.village_channel.add_reaction("👎") + await message.add_reaction("👍") + await message.add_reaction("👎") await asyncio.sleep(15) - reaction_list = message.reactions - up_votes = sum(p.emoji == "👍" and not p.me for p in reaction_list) - down_votes = sum(p.emoji == "👎" and not p.me for p in reaction_list) + 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) - if len(down_votes) > len(up_votes): + if down_votes > up_votes: embed = discord.Embed(title="Vote Results", color=0xff0000) else: embed = discord.Embed(title="Vote Results", color=0x80ff80) - embed.add_field(name="👎", value="**{}**".format(len(up_votes)), inline=True) - embed.add_field(name="👍", value="**{}**".format(len(down_votes)), inline=True) + embed.add_field(name="👎", value="**{}**".format(up_votes), inline=True) + embed.add_field(name="👍", value="**{}**".format(down_votes), inline=True) await self.village_channel.send(embed=embed) - if len(down_votes) > len(up_votes): + if down_votes > up_votes: await self.village_channel.send("**Voted to lynch {}!**".format(target.mention)) await self.lynch(target) + self.can_vote = False else: await self.village_channel.send("**{} has been spared!**".format(target.mention)) if self.used_votes >= self.day_vote_count: await self.village_channel.send("**All votes have been used! Day is now over!**") + self.can_vote = False else: await self.village_channel.send( "**{}**/**{}** of today's votes have been used!\n" "Nominate carefully..".format(self.used_votes, self.day_vote_count)) - self.can_vote = True # Only re-enable voting if more votes remain + + self.ongoing_vote = False if not self.can_vote: await self._at_day_end() + else: + await self.normal_perms(self.village_channel) # No point if about to be night async def _at_kill(self, target): # ID 3 if self.game_over: @@ -329,7 +391,7 @@ class Game: return await self._notify(7) - await asyncio.sleep(15) + await asyncio.sleep(10) await self._at_day_start() async def _at_visit(self, target, source): # ID 8 @@ -355,16 +417,22 @@ class Game: ############END Notify structure############ - async def generate_targets(self, channel): + async def generate_targets(self, channel, with_roles=False): embed = discord.Embed(title="Remaining Players") for i in range(len(self.players)): player = self.players[i] if player.alive: status = "" else: - status = "*Dead*" - embed.add_field(name="ID# **{}**".format(i), - value="{} {}".format(status, player.member.display_name), inline=True) + status = "*[Dead]*-" + if with_roles or not player.alive: + embed.add_field(name="ID# **{}**".format(i), + value="{}{}-{}".format(status, player.member.display_name, str(player.role)), + inline=True) + else: + embed.add_field(name="ID# **{}**".format(i), + value="{}{}".format(status, player.member.display_name), + inline=True) return await channel.send(embed=embed) @@ -400,6 +468,13 @@ class Game: self.players.append(Player(member)) + if self.game_role is not None: + try: + await member.add_roles(*[self.game_role]) + except discord.Forbidden: + await channel.send( + "Unable to add role **{}**\nBot is missing `manage_roles` permissions".format(self.game_role.name)) + await channel.send("{} has been added to the game, " "total players is **{}**".format(member.mention, len(self.players))) @@ -417,6 +492,7 @@ class Game: await channel.send("{} has left the game".format(member.mention)) else: self.players = [player for player in self.players if player.member != member] + await member.remove_roles(*[self.game_role]) await channel.send("{} chickened out, player count is now **{}**".format(member.mention, len(self.players))) async def choose(self, ctx, data): @@ -431,7 +507,7 @@ class Game: return if not player.alive: - await ctx.send("**Corpses** can't vote...") + await ctx.send("**Corpses** can't participate...") return if player.role.blocked: @@ -441,7 +517,7 @@ class Game: # Let role do target validation, might be alternate targets # I.E. Go on alert? y/n - await player.choose(ctx, data) + await player.role.choose(ctx, data) async def _visit(self, target, source): await target.role.visit(source) @@ -471,7 +547,7 @@ class Game: return if not player.alive: - await channel.send("Corpses can't vote") + await channel.send("Corpses can't vote...") return if channel == self.village_channel: @@ -531,7 +607,9 @@ class Game: out = "**{ID}** - " + method return out.format(ID=target.id, target=target.member.display_name) else: - return "**{ID}** - {target} was found dead".format(ID=target.id, target=target.member.display_name) + return "**{ID}** - {target} the {role} was found dead".format(ID=target.id, + target=target.member.display_name, + role=await target.role.get_role()) async def _quit(self, player): """ @@ -595,14 +673,25 @@ 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 set_code(self, ctx: commands.Context, game_code): + if game_code is not None: + self.game_code = game_code + await ctx.send("Code has been set") + + 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) + try: + self.roles = await parse_code(self.game_code, self) + except ValueError as e: + await ctx.send("Invalid Code: Code contains unknown character\n{}".format(e)) + return False + except IndexError as e: + await ctx.send("Invalid Code: Code references unknown role\n{}".format(e)) if not self.roles: return False @@ -613,11 +702,10 @@ class Game: self.players.sort(key=lambda pl: pl.member.display_name.lower()) if len(self.roles) != len(self.players): - await self.village_channel("Unhandled error - roles!=players") + await self.village_channel.send("Unhandled error - roles!=players") return False for idx, role in enumerate(self.roles): - self.roles[idx] = role(self) await self.roles[idx].assign_player(self.players[idx]) # Sorted players, now assign id's await self.players[idx].assign_id(idx) @@ -645,28 +733,67 @@ class Game: await channel.set_permissions(self.game_role, read_messages=True, send_messages=False) await channel.set_permissions(member, send_messages=True) - async def normal_perms(self, channel, member_list): + async def normal_perms(self, channel): await channel.set_permissions(self.game_role, read_messages=True, send_messages=True) - # for member in member_list: - # await channel.set_permissions(member, read_messages=True) async def _check_game_over(self): - alive_players = [player for player self.players if player.alive] - - if len(alive_players)<=2: + # return # ToDo: re-enable game-over checking + alive_players = [player for player in self.players if player.alive] + + if len(alive_players) <= 0: + await self.village_channel.send(embed=discord.Embed(title="**Everyone is dead! Game Over!**")) + self.game_over = True + elif len(alive_players) == 1: + self.game_over = True + await self._announce_winners(alive_players) + elif len(alive_players) == 2: # Check 1v1 victory conditions ToDo - pass + self.game_over = True + alignment1 = alive_players[0].role.alignment + alignment2 = alive_players[1].role.alignment + if alignment1 == alignment2: # Same team + winners = alive_players + else: + winners = [max(alive_players, key=lambda p: p.role.alignment)] + + await self._announce_winners(winners) else: - #Check if everyone is on the same team - alignment = alive_players[0].role.alignment + # Check if everyone is on the same team + alignment = alive_players[0].role.alignment # Get first allignment and compare to rest for player in alive_players: if player.role.alignment != alignment: - return False + return # Only remaining team wins + self.game_over = True + await self._announce_winners(alive_players) + + # If no return, cleanup and end game + await self._end_game() + + async def _announce_winners(self, winnerlist): + await self.village_channel.send(self.game_role.mention) + embed = discord.Embed(title='Game Over', description='The Following Players have won!') + for player in winnerlist: + embed.add_field(name=player.member.display_name, value=str(player.role), inline=True) + embed.set_thumbnail(url='https://emojipedia-us.s3.amazonaws.com/thumbs/160/twitter/134/trophy_1f3c6.png') + await self.village_channel.send(embed=embed) - + await self.generate_targets(self.village_channel, True) async def _end_game(self): - # ToDo - pass + # Remove game_role access for potential archiving for now + reason = '(BOT) End of WW game' + for obj in self.to_delete: + print(obj) + await obj.delete(reason=reason) + + try: + await self.village_channel.edit(reason=reason, name="Werewolf") + for target, overwrites in self.save_perms[self.village_channel]: + await self.village_channel.set_permissions(target, overwrite=overwrites, reason=reason) + await self.village_channel.set_permissions(self.game_role, overwrite=None, reason=reason) + except (discord.HTTPException, discord.NotFound, discord.errors.NotFound): + pass + + # Optional dynamic channels/categories diff --git a/werewolf/role.py b/werewolf/role.py index 64c78a4..a2e0a52 100644 --- a/werewolf/role.py +++ b/werewolf/role.py @@ -46,6 +46,12 @@ class Role: "You win by testing the game\n" "Lynch players during the day with `[p]ww vote `" ) + description = ( + "This is the basic role\n" + "All roles are based on this Class" + "Has no special significance" + ) + icon_url = None # Adding a URL here will enable a thumbnail of the role def __init__(self, game): self.game = game @@ -65,6 +71,9 @@ class Role: (self._at_visit, 0) ] + def __repr__(self): + return self.__class__.__name__ + async def on_event(self, event, data): """ See Game class for event guide diff --git a/werewolf/roles/seer.py b/werewolf/roles/seer.py index ccd61be..96260d9 100644 --- a/werewolf/roles/seer.py +++ b/werewolf/roles/seer.py @@ -2,7 +2,7 @@ from werewolf.role import Role class Seer(Role): - rand_choice = False # Determines if it can be picked as a random role (False for unusually disruptive roles) + rand_choice = True # Determines if it can be picked as a random role (False for unusually disruptive roles) category = [1, 2] # List of enrolled categories (listed above) alignment = 1 # 1: Town, 2: Werewolf, 3: Neutral channel_id = "" # Empty for no private channel @@ -29,7 +29,8 @@ class Seer(Role): (self._at_hang, 0), (self._at_day_end, 0), (self._at_night_start, 2), - (self._at_night_end, 4) + (self._at_night_end, 4), + (self._at_visit, 0) ] # async def on_event(self, event, data): @@ -96,15 +97,22 @@ class Seer(Role): # pass async def _at_night_start(self, data=None): + if not self.player.alive: + return + self.see_target = None await self.game.generate_targets(self.player.member) - await self.player.send_dm("{}\n**Pick a target to see tonight**\n") + await self.player.send_dm("**Pick a target to see tonight**\n") async def _at_night_end(self, data=None): - target = await self.game.visit(self.see_target) + if self.see_target is None: + if self.player.alive: + await self.player.send_dm("You will not use your powers tonight...") + return + target = await self.game.visit(self.see_target, self.player) alignment = None if target: - alignment = await target.see_alignment(self.player) + alignment = await target.role.see_alignment(self.player) if alignment == "Werewolf": out = "Your insight reveals this player to be a **Werewolf!**" @@ -133,6 +141,10 @@ class Seer(Role): async def choose(self, ctx, data): """Handle night actions""" + if not self.player.alive: # FixMe: Game handles this? + await self.player.send_dm("You're already dead!") + return + target_id = int(data) try: target = self.game.players[target_id] diff --git a/werewolf/roles/villager.py b/werewolf/roles/villager.py index f1b8016..4935275 100644 --- a/werewolf/roles/villager.py +++ b/werewolf/roles/villager.py @@ -2,7 +2,7 @@ from werewolf.role import Role class Villager(Role): - rand_choice = False # Determines if it can be picked as a random role (False for unusually disruptive roles) + rand_choice = True # Determines if it can be picked as a random role (False for unusually disruptive roles) category = [1] # List of enrolled categories (listed above) alignment = 1 # 1: Town, 2: Werewolf, 3: Neutral channel_id = "" # Empty for no private channel diff --git a/werewolf/werewolf.py b/werewolf/werewolf.py index c5bf57e..65e73c1 100644 --- a/werewolf/werewolf.py +++ b/werewolf/werewolf.py @@ -1,9 +1,12 @@ import discord from discord.ext import commands -from redbot.core import Config -from redbot.core import RedContext +from redbot.core import Config, checks +from redbot.core.bot import Red + +from werewolf.builder import GameBuilder, role_from_name, role_from_alignment, role_from_category, role_from_id from werewolf.game import Game +from redbot.core.utils.menus import menu, DEFAULT_CONTROLS class Werewolf: @@ -11,12 +14,15 @@ class Werewolf: Base to host werewolf on a guild """ - def __init__(self, bot): + def __init__(self, bot: Red): self.bot = bot self.config = Config.get_conf(self, identifier=87101114101119111108102, force_registration=True) default_global = {} default_guild = { - "role": None + "role_id": None, + "category_id": None, + "channel_id": None, + "log_channel_id": None } self.config.register_global(**default_global) @@ -29,26 +35,102 @@ class Werewolf: for game in self.games.values(): del game + @commands.command() + async def buildgame(self, ctx: commands.Context): + gb = GameBuilder() + code = await gb.build_game(ctx) + + if code != "": + await ctx.send("Your game code is **{}**".format(code)) + else: + await ctx.send("No code generated") + + @checks.guildowner() @commands.group() - async def wwset(self, ctx: RedContext): + async def wwset(self, ctx: commands.Context): """ Base command to adjust settings. Check help for command list. """ if ctx.invoked_subcommand is None: await ctx.send_help() + @commands.guild_only() + @wwset.command(name="list") + async def wwset_list(self, ctx: commands.Context): + """ + Lists current guild settings + """ + success, role, category, channel, log_channel = await self._get_settings(ctx) + if not success: + await ctx.send("Failed to get settings") + return None + + embed = discord.Embed(title="Current Guild Settings") + embed.add_field(name="Role", value=str(role)) + embed.add_field(name="Category", value=str(category)) + embed.add_field(name="Channel", value=str(channel)) + embed.add_field(name="Log Channel", value=str(log_channel)) + await ctx.send(embed=embed) + @commands.guild_only() @wwset.command(name="role") - async def wwset_role(self, ctx, role: discord.Role): + async def wwset_role(self, ctx: commands.Context, role: discord.Role=None): """ Assign the game role This role should not be manually assigned """ - await self.config.guild(ctx.guild).role.set(role.id) - await ctx.send("Game role has been set to **{}**".format(role.name)) + if role is None: + await self.config.guild(ctx.guild).role_id.set(None) + await ctx.send("Cleared Game Role") + else: + await self.config.guild(ctx.guild).role_id.set(role.id) + await ctx.send("Game Role has been set to **{}**".format(role.name)) + + @commands.guild_only() + @wwset.command(name="category") + async def wwset_category(self, ctx: commands.Context, category_id=None): + """ + Assign the channel category + """ + if category_id is None: + await self.config.guild(ctx.guild).category_id.set(None) + await ctx.send("Cleared Game Channel Category") + else: + category = discord.utils.get(ctx.guild.categories, id=int(category_id)) + if category is None: + await ctx.send("Category not found") + return + await self.config.guild(ctx.guild).category_id.set(category.id) + await ctx.send("Game Channel Category has been set to **{}**".format(category.name)) + + @commands.guild_only() + @wwset.command(name="channel") + async def wwset_channel(self, ctx: commands.Context, channel: discord.TextChannel=None): + """ + Assign the village channel + """ + if channel is None: + await self.config.guild(ctx.guild).channel_id.set(None) + await ctx.send("Cleared Game Channel") + else: + await self.config.guild(ctx.guild).channel_id.set(channel.id) + await ctx.send("Game Channel has been set to **{}**".format(channel.mention)) + + @commands.guild_only() + @wwset.command(name="logchannel") + async def wwset_log_channel(self, ctx: commands.Context, channel: discord.TextChannel=None): + """ + Assign the log channel + """ + if channel is None: + await self.config.guild(ctx.guild).log_channel_id.set(None) + await ctx.send("Cleared Game Log Channel") + else: + await self.config.guild(ctx.guild).log_channel_id.set(channel.id) + await ctx.send("Game Log Channel has been set to **{}**".format(channel.mention)) @commands.group() - async def ww(self, ctx: RedContext): + async def ww(self, ctx: commands.Context): """ Base command for this cog. Check help for the commands list. """ @@ -56,27 +138,25 @@ class Werewolf: await ctx.send_help() @commands.guild_only() - @ww.command() - async def new(self, ctx, game_code): + @ww.command(name="new") + async def ww_new(self, ctx: commands.Context, game_code=None): """ Create and join a new game of Werewolf """ - - game = await self._get_game(ctx.guild, game_code) - + game = await self._get_game(ctx, game_code) if not game: await ctx.send("Failed to start a new game") else: - await ctx.send("New game has started") + await ctx.send("Game is ready to join! Use `[p]ww join`") @commands.guild_only() - @ww.command() - async def join(self, ctx): + @ww.command(name="join") + async def ww_join(self, ctx: commands.Context): """ Joins a game of Werewolf """ - game = await self._get_game(ctx.guild) + game = await self._get_game(ctx) if not game: await ctx.send("No game to join!\nCreate a new one with `[p]ww new`") @@ -85,49 +165,71 @@ class Werewolf: await game.join(ctx.author, ctx.channel) @commands.guild_only() - @ww.command() - async def quit(self, ctx): + @ww.command(name="code") + async def ww_code(self, ctx: commands.Context, code): + """ + Adjust game code + """ + + game = await self._get_game(ctx) + + if not game: + await ctx.send("No game to join!\nCreate a new one with `[p]ww new`") + return + + await game.set_code(ctx, code) + + @commands.guild_only() + @ww.command(name="quit") + async def ww_quit(self, ctx: commands.Context): """ Quit a game of Werewolf """ - game = await self._get_game(ctx.guild) + game = await self._get_game(ctx) await game.quit(ctx.author, ctx.channel) @commands.guild_only() - @ww.command() - async def start(self, ctx): + @ww.command(name="start") + async def ww_start(self, ctx: commands.Context): """ Checks number of players and attempts to start the game """ - game = await self._get_game(ctx.guild) + game = await self._get_game(ctx) if not game: await ctx.send("No game running, cannot start") - await game.setup(ctx) + if not await game.setup(ctx): + pass # Do something? @commands.guild_only() - @ww.command() - async def stop(self, ctx): + @ww.command(name="stop") + async def ww_stop(self, ctx: commands.Context): """ Stops the current game """ - game = await self._get_game(ctx.guild) - if not game: - await ctx.send("No game running, cannot stop") + if ctx.guild is None: + # Private message, can't get guild + await ctx.send("Cannot start game from PM!") + return + if ctx.guild.id not in self.games or self.games[ctx.guild.id].game_over: + await ctx.send("No game to stop") + return + game = await self._get_game(ctx) game.game_over = True + await ctx.send("Game has been stopped") @commands.guild_only() - @ww.command() - async def vote(self, ctx, target_id: int): + @ww.command(name="vote") + async def ww_vote(self, ctx: commands.Context, target_id: int): """ Vote for a player by ID """ try: target_id = int(target_id) - except: + except ValueError: target_id = None if target_id is None: @@ -145,7 +247,7 @@ class Werewolf: # return # else: - game = await self._get_game(ctx.guild) + game = await self._get_game(ctx) if game is None: await ctx.send("No game running, cannot vote") @@ -160,8 +262,8 @@ class Werewolf: else: await ctx.send("Nothing to vote for in this channel") - @ww.command() - async def choose(self, ctx, data): + @ww.command(name="choose") + async def ww_choose(self, ctx: commands.Context, data): """ Arbitrary decision making Handled by game+role @@ -171,7 +273,6 @@ class Werewolf: if ctx.guild is not None: await ctx.send("This action is only available in DM's") return - # DM nonsense, find their game # If multiple games, panic for game in self.games.values(): @@ -183,20 +284,108 @@ class Werewolf: await game.choose(ctx, data) - async def _get_game(self, guild, game_code=None): + @ww.group(name="search") + async def ww_search(self, ctx: commands.Context): + """ + Find custom roles by name, alignment, category, or ID + """ + if ctx.invoked_subcommand is None or ctx.invoked_subcommand == self.ww_search: + await ctx.send_help() + + @ww_search.command(name="name") + async def ww_search_name(self, ctx: commands.Context, *, name): + """Search for a role by name""" + if name is not None: + from_name = role_from_name(name) + if from_name: + await menu(ctx, from_name, DEFAULT_CONTROLS) + else: + await ctx.send("No roles containing that name were found") + + @ww_search.command(name="alignment") + async def ww_search_alignment(self, ctx: commands.Context, alignment: int): + """Search for a role by alignment""" + if alignment is not None: + from_alignment = role_from_alignment(alignment) + if from_alignment: + await menu(ctx, from_alignment, DEFAULT_CONTROLS) + else: + await ctx.send("No roles with that alignment were found") + + @ww_search.command(name="category") + async def ww_search_category(self, ctx: commands.Context, category: int): + """Search for a role by category""" + if category is not None: + pages = role_from_category(category) + if pages: + await menu(ctx, pages, DEFAULT_CONTROLS) + else: + await ctx.send("No roles in that category were found") + + @ww_search.command(name="index") + async def ww_search_index(self, ctx: commands.Context, idx: int): + """Search for a role by ID""" + if idx is not None: + idx_embed = role_from_id(idx) + if idx_embed is not None: + await ctx.send(embed=idx_embed) + else: + await ctx.send("Role ID not found") + + async def _get_game(self, ctx: commands.Context, game_code=None): + guild: discord.Guild = ctx.guild + if guild is None: # Private message, can't get guild + await ctx.send("Cannot start game from PM!") return None - if guild.id not in self.games: - if not game_code: - return None - role = await self.config.guild(guild).role() - role = discord.utils.get(guild.roles, id=role) - if role is None: + if guild.id not in self.games or self.games[guild.id].game_over: + await ctx.send("Starting a new game...") + success, role, category, channel, log_channel = await self._get_settings(ctx) + + if not success: + await ctx.send("Cannot start a new game") return None - self.games[guild.id] = Game(guild, role, game_code) + + self.games[guild.id] = Game(guild, role, category, channel, log_channel, game_code) return self.games[guild.id] async def _game_start(self, game): await game.start() + + async def _get_settings(self, ctx): + guild = ctx.guild + role = None + category = None + channel = None + log_channel = None + + role_id = await self.config.guild(guild).role_id() + category_id = await self.config.guild(guild).category_id() + channel_id = await self.config.guild(guild).channel_id() + log_channel_id = await self.config.guild(guild).log_channel_id() + + if role_id is not None: + role = discord.utils.get(guild.roles, id=role_id) + if role is None: + await ctx.send("Game Role is invalid") + return False, None, None, None, None + if category_id is not None: + category = discord.utils.get(guild.categories, id=category_id) + if category is None: + await ctx.send("Game Category is invalid") + return False, None, None, None, None + if channel_id is not None: + channel = discord.utils.get(guild.text_channels, id=channel_id) + if channel is None: + await ctx.send("Village Channel is invalid") + return False, None, None, None, None + if log_channel_id is not None: + log_channel = discord.utils.get(guild.text_channels, id=log_channel_id) + if log_channel is None: + await ctx.send("Log Channel is invalid") + return False, None, None, None, None + + return True, role, category, channel, log_channel +