diff --git a/chatter/chat.py b/chatter/chat.py index ad8e37b..ef75bb8 100644 --- a/chatter/chat.py +++ b/chatter/chat.py @@ -434,7 +434,7 @@ class Chatter(Cog): else: await ctx.maybe_send_embed("Error occurred :(") - @commands.Cog.listener() + @Cog.listener() async def on_message_without_command(self, message: discord.Message): """ Credit to https://github.com/Twentysix26/26-Cogs/blob/master/cleverbot/cleverbot.py diff --git a/werewolf/builder.py b/werewolf/builder.py index 2ed34a2..f57a669 100644 --- a/werewolf/builder.py +++ b/werewolf/builder.py @@ -1,5 +1,7 @@ import bisect +import logging from collections import defaultdict +from operator import attrgetter from random import choice import discord @@ -8,77 +10,55 @@ import discord # Import all roles here from redbot.core import commands -from .roles.seer import Seer -from .roles.vanillawerewolf import VanillaWerewolf -from .roles.villager import Villager -from redbot.core.utils.menus import menu, prev_page, next_page, close_menu +# from .roles.seer import Seer +# from .roles.vanillawerewolf import VanillaWerewolf +# from .roles.villager import Villager -# All roles in this list for iterating +from werewolf import roles +from redbot.core.utils.menus import menu, prev_page, next_page, close_menu -ROLE_LIST = sorted([Villager, Seer, VanillaWerewolf], key=lambda x: x.alignment) +from werewolf.constants import ROLE_CATEGORY_DESCRIPTIONS +from werewolf.role import Role -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]] +log = logging.getLogger("red.fox_v3.werewolf.builder") -ROLE_PAGES = [] -PAGE_GROUPS = [0] +# All roles in this list for iterating -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"} +ROLE_DICT = {name: cls for name, cls in roles.__dict__.items() if isinstance(cls, type)} +ROLE_LIST = sorted( + [cls for cls in ROLE_DICT.values()], + key=attrgetter("alignment"), +) -CATEGORY_COUNT = [] +log.debug(f"{ROLE_DICT=}") +# Town, Werewolf, Neutral +ALIGNMENT_COLORS = [0x008000, 0xFF0000, 0xC0C0C0] -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) +ROLE_PAGES = [] - return embed +def role_embed(idx, role: Role, color): + embed = discord.Embed( + title=f"**{idx}** - {role.__name__}", + description=role.game_start_message, + color=color, + ) + if role.icon_url is not None: + embed.set_thumbnail(url=role.icon_url) + + embed.add_field( + name="Alignment", value=["Town", "Werewolf", "Neutral"][role.alignment - 1], inline=False + ) + embed.add_field(name="Multiples Allowed", value=str(not role.unique), inline=False) + embed.add_field( + name="Role Types", + value=", ".join(ROLE_CATEGORY_DESCRIPTIONS[x] for x in role.category), + inline=False, + ) + embed.add_field(name="Random Option", value=str(role.rand_choice), inline=False) -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) + return embed """ @@ -147,15 +127,15 @@ async def parse_code(code, game): return decode -async def encode(roles, rand_roles): +async def encode(role_list, rand_roles): """Convert role list to code""" out_code = "" - digit_sort = sorted(role for role in roles if role < 10) + digit_sort = sorted(role for role in role_list if role < 10) for role in digit_sort: out_code += str(role) - digit_sort = sorted(role for role in roles if 10 <= role < 100) + digit_sort = sorted(role for role in role_list if 10 <= role < 100) if digit_sort: out_code += "-" for role in digit_sort: @@ -187,49 +167,20 @@ async def encode(roles, rand_roles): 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] + 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] + 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): @@ -242,8 +193,11 @@ def role_from_id(idx): 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__] + 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): @@ -255,34 +209,87 @@ def say_role_list(code_list, rand_roles): for role in rand_roles: if 0 < role <= 6: - role_dict["Town {}".format(ROLE_CATEGORIES[role])] += 1 + role_dict[f"Town {ROLE_CATEGORY_DESCRIPTIONS[role]}"] += 1 if 10 < role <= 16: - role_dict["Werewolf {}".format(ROLE_CATEGORIES[role])] += 1 + role_dict[f"Werewolf {ROLE_CATEGORY_DESCRIPTIONS[role]}"] += 1 if 20 < role <= 26: - role_dict["Neutral {}".format(ROLE_CATEGORIES[role])] += 1 + role_dict[f"Neutral {ROLE_CATEGORY_DESCRIPTIONS[role]}"] += 1 for k, v in role_dict.items(): - embed.add_field(name=k, value="Count: {}".format(v), inline=True) + embed.add_field(name=k, value=f"Count: {v}", inline=True) return embed class GameBuilder: - def __init__(self): self.code = [] self.rand_roles = [] - setup() + self.page_groups = [0] + self.category_count = [] + + self.setup() + + def setup(self): + # 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 self.page_groups: + self.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 self.page_groups: + self.page_groups.append(len(ROLE_PAGES) - 1) + for k, v in ROLE_CATEGORY_DESCRIPTIONS.items(): + if 0 < k <= 9: + ROLE_PAGES.append( + discord.Embed( + title="RANDOM:Town Role", + description=f"Town {v}", + color=ALIGNMENT_COLORS[0], + ) + ) + self.category_count.append(k) + + # Random WW Roles + if len(ROLE_PAGES) - 1 not in self.page_groups: + self.page_groups.append(len(ROLE_PAGES) - 1) + for k, v in ROLE_CATEGORY_DESCRIPTIONS.items(): + if 10 < k <= 19: + ROLE_PAGES.append( + discord.Embed( + title="RANDOM:Werewolf Role", + description=f"Werewolf {v}", + color=ALIGNMENT_COLORS[1], + ) + ) + self.category_count.append(k) + # Random Neutral Roles + if len(ROLE_PAGES) - 1 not in self.page_groups: + self.page_groups.append(len(ROLE_PAGES) - 1) + for k, v in ROLE_CATEGORY_DESCRIPTIONS.items(): + if 20 < k <= 29: + ROLE_PAGES.append( + discord.Embed( + title=f"RANDOM:Neutral Role", + description=f"Neutral {v}", + color=ALIGNMENT_COLORS[2], + ) + ) + self.category_count.append(k) async def build_game(self, ctx: commands.Context): new_controls = { - '⏪': prev_group, + "⏪": self.prev_group, "⬅": prev_page, - '☑': self.select_page, + "☑": self.select_page, "➡": next_page, - '⏩': next_group, - '📇': self.list_roles, - "❌": close_menu + "⏩": self.next_group, + "📇": self.list_roles, + "❌": close_menu, } await ctx.send("Browse through roles and add the ones you want using the check mark") @@ -292,10 +299,17 @@ class GameBuilder: 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) + 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.me) if perms.manage_messages: # Can manage messages, so remove react try: await message.remove_reaction(emoji, ctx.author) @@ -304,13 +318,19 @@ class GameBuilder: 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) + 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.me) if perms.manage_messages: # Can manage messages, so remove react try: await message.remove_reaction(emoji, ctx.author) @@ -318,9 +338,53 @@ class GameBuilder: pass if page >= len(ROLE_LIST): - self.rand_roles.append(CATEGORY_COUNT[page - len(ROLE_LIST)]) + self.rand_roles.append(self.category_count[page - len(ROLE_LIST)]) else: self.code.append(page) - return await menu(ctx, pages, controls, message=message, - page=page, timeout=timeout) + return await menu(ctx, pages, controls, message=message, page=page, timeout=timeout) + + async def next_group( + self, + ctx: commands.Context, + pages: list, + controls: dict, + message: discord.Message, + page: int, + timeout: float, + emoji: str, + ): + perms = message.channel.permissions_for(ctx.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(self.page_groups, page) + + if page == len(self.page_groups): + page = self.page_groups[0] + else: + page = self.page_groups[page] + + return await menu(ctx, pages, controls, message=message, page=page, timeout=timeout) + + async def prev_group( + self, + ctx: commands.Context, + pages: list, + controls: dict, + message: discord.Message, + page: int, + timeout: float, + emoji: str, + ): + perms = message.channel.permissions_for(ctx.me) + if perms.manage_messages: # Can manage messages, so remove react + try: + await message.remove_reaction(emoji, ctx.author) + except discord.NotFound: + pass + page = self.page_groups[bisect.bisect_left(self.page_groups, page) - 1] + + return await menu(ctx, pages, controls, message=message, page=page, timeout=timeout) diff --git a/werewolf/constants.py b/werewolf/constants.py new file mode 100644 index 0000000..bb77421 --- /dev/null +++ b/werewolf/constants.py @@ -0,0 +1,91 @@ +""" +Role Constants + + Role Alignment guide as follows: + Town: 1 + Werewolf: 2 + Neutral: 3 + + Additional alignments may be added when warring factions are added + (Rival werewolves, cultists, vampires) + + Role Category enrollment guide as follows (See Role.category): + Town: + 1: Random, 2: Investigative, 3: Protective, 4: Government, + 5: Killing, 6: Power (Special night action) + + Werewolf: + 11: Random, 12: Deception, 15: Killing, 16: Support + + Neutral: + 21: Benign, 22: Evil, 23: Killing + + + Example category: + category = [1, 5, 6] Could be Veteran + category = [1, 5] Could be Bodyguard + category = [11, 16] Could be Werewolf Silencer + category = [22] Could be Blob (non-killing) + category = [22, 23] Could be Serial-Killer +""" + + +ALIGNMENT_TOWN = 1 +ALIGNMENT_WEREWOLF = 2 +ALIGNMENT_NEUTRAL = 3 +ALIGNMENT_MAP = {"Town": 1, "Werewolf": 2, "Neutral": 3} + +# 0-9: Town Role Categories +# 10-19: Werewolf Role Categories +# 20-29: Neutral Role Categories +CATEGORY_TOWN_RANDOM = 1 +CATEGORY_TOWN_INVESTIGATIVE = 2 +CATEGORY_TOWN_PROTECTIVE = 3 +CATEGORY_TOWN_GOVERNMENT = 4 +CATEGORY_TOWN_KILLING = 5 +CATEGORY_TOWN_POWER = 6 + +CATEGORY_WW_RANDOM = 11 +CATEGORY_WW_DECEPTION = 12 +CATEGORY_WW_KILLING = 15 +CATEGORY_WW_SUPPORT = 16 + +CATEGORY_NEUTRAL_BENIGN = 21 +CATEGORY_NEUTRAL_EVIL = 22 +CATEGORY_NEUTRAL_KILLING = 23 + +ROLE_CATEGORY_DESCRIPTIONS = { + CATEGORY_TOWN_RANDOM: "Random", + CATEGORY_TOWN_INVESTIGATIVE: "Investigative", + CATEGORY_TOWN_PROTECTIVE: "Protective", + CATEGORY_TOWN_GOVERNMENT: "Government", + CATEGORY_TOWN_KILLING: "Killing", + CATEGORY_TOWN_POWER: "Power (Special night action)", + CATEGORY_WW_RANDOM: "Random", + CATEGORY_WW_DECEPTION: "Deception", + CATEGORY_WW_KILLING: "Killing", + CATEGORY_WW_SUPPORT: "Support", + CATEGORY_NEUTRAL_BENIGN: "Benign", + CATEGORY_NEUTRAL_EVIL: "Evil", + CATEGORY_NEUTRAL_KILLING: "Killing", +} + + +""" +Listener Actions Priority Guide + + Action priority guide as follows (see listeners.py for wolflistener): + _at_night_start + 0. No Action + 1. Detain actions (Jailer/Kidnapper) + 2. Group discussions and choose targets + + _at_night_end + 0. No Action + 1. Self actions (Veteran) + 2. Target switching and role blocks (bus driver, witch, escort) + 3. Protection / Preempt actions (bodyguard/framer) + 4. Non-disruptive actions (seer/silencer) + 5. Disruptive actions (Killing) + 6. Role altering actions (Cult / Mason / Shifter) +""" diff --git a/werewolf/converters.py b/werewolf/converters.py new file mode 100644 index 0000000..f108666 --- /dev/null +++ b/werewolf/converters.py @@ -0,0 +1,28 @@ +from typing import TYPE_CHECKING, Union + +import discord +from discord.ext.commands import BadArgument, Converter +from redbot.core import commands + +from werewolf.player import Player + +if TYPE_CHECKING: + PlayerConverter = Union[int, discord.Member] + CronConverter = str +else: + + class PlayerConverter(Converter): + async def convert(self, ctx, argument) -> Player: + + try: + target = await commands.MemberConverter().convert(ctx, argument) + except BadArgument: + try: + target = int(argument) + assert target >= 0 + except (ValueError, AssertionError): + raise BadArgument + + # TODO: Get the game for context without making a new one + # TODO: Get player from game based on either ID or member object + return target diff --git a/werewolf/game.py b/werewolf/game.py index a64ace1..668bf16 100644 --- a/werewolf/game.py +++ b/werewolf/game.py @@ -1,20 +1,39 @@ import asyncio +import logging import random -from typing import List, Any, Dict, Set, Union +from collections import deque +from typing import Dict, List, Union import discord from redbot.core import commands +from redbot.core.bot import Red +from redbot.core.utils import AsyncIter -from .builder import parse_code -from .player import Player -from .role import Role -from .votegroup import VoteGroup +from werewolf.builder import parse_code +from werewolf.constants import ALIGNMENT_NEUTRAL +from werewolf.player import Player +from werewolf.role import Role +from werewolf.votegroup import VoteGroup + +log = logging.getLogger("red.fox_v3.werewolf.game") + +HALF_DAY_LENGTH = 90 # FixMe: Make configurable +HALF_NIGHT_LENGTH = 60 + + +async def anyone_has_role( + member_list: List[discord.Member], role: discord.Role +) -> Union[None, discord.Member]: + return await AsyncIter(member_list).find( + lambda m: AsyncIter(m.roles).find(lambda r: r.id == role.id) + ) class Game: """ Base class to run a single game of Werewolf """ + vote_groups: Dict[str, VoteGroup] roles: List[Role] players: List[Player] @@ -22,19 +41,29 @@ class Game: default_secret_channel = { "channel": None, "players": [], - "votegroup": None # uninitialized VoteGroup + "votegroup": None, # uninitialized VoteGroup } - morning_messages = [ - "**The sun rises on day {} in the village..**", - "**Morning has arrived on day {}..**" + day_start_messages = [ + "*The sun rises on day {} in the village..*", + "*Morning has arrived on day {}..*", ] + day_end_messages = ["*Dawn falls..*", "*The sun sets on the village*"] + day_vote_count = 3 - 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): + def __init__( + self, + bot: Red, + guild: discord.Guild, + role: discord.Role = None, + category: discord.CategoryChannel = None, + village: discord.TextChannel = None, + log_channel: discord.TextChannel = None, + game_code=None, + ): + self.bot = bot self.guild = guild self.game_code = game_code @@ -46,7 +75,7 @@ class Game: self.started = False self.game_over = False - self.can_vote = False + self.any_votes_remaining = False self.used_votes = 0 self.day_time = False @@ -68,6 +97,10 @@ class Game: self.loop = asyncio.get_event_loop() + self.action_queue = deque() + self.current_action = None + self.listeners = {} + # def __del__(self): # """ # Cleanup channels as necessary @@ -97,47 +130,72 @@ class Game: await self.get_roles(ctx) if len(self.players) != len(self.roles): - await ctx.maybe_send_embed("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)) + await ctx.maybe_send_embed( + f"Player count does not match role count, cannot start\n" + f"Currently **{len(self.players)} / {len(self.roles)}**\n" + f"Use `{ctx.prefix}ww code` to pick a game setup\n" + f"Use `{ctx.prefix}buildgame` to generate a new game" + ) self.roles = [] return False + # If there's no game role, make the role and delete it later in `self.to_delete` if self.game_role is None: try: - self.game_role = await ctx.guild.create_role(name="WW Players", - hoist=True, - mentionable=True, - reason="(BOT) Werewolf game role") + 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.maybe_send_embed("Game role not configured and unable to generate one, cannot start") + await ctx.maybe_send_embed( + "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 + + anyone_with_role = await anyone_has_role(self.guild.members, self.game_role) + if anyone_with_role is not None: + await ctx.maybe_send_embed( + f"{anyone_with_role.display_name} has the game role, " + f"can't continue until no one has the role" + ) + return False + + try: + for player in self.players: + await player.member.add_roles(*[self.game_role]) + except discord.Forbidden: + log.exception(f"Unable to add role **{self.game_role.name}**") + await ctx.maybe_send_embed( + f"Unable to add role **{self.game_role.name}**\n" + f"Bot is missing `manage_roles` permissions" + ) + return False await self.assign_roles() # Create category and channel with individual overwrites 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, - manage_messages=True, manage_channels=True, - manage_roles=True), - self.game_role: discord.PermissionOverwrite(read_messages=True, send_messages=True) + 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, + 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") + 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") @@ -147,60 +205,76 @@ class Game: # 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) + 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") + await ctx.maybe_send_embed( + "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") + await self.village_channel.edit( + name="🔵werewolf", + 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") + log.exception("Unable to rename Game Channel") + await ctx.maybe_send_embed("Unable to rename Game Channel, ignoring") 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") + 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") + await ctx.maybe_send_embed( + "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 - print("Post at_game_start") - for channel_id in self.p_channels: - print("Channel id: " + channel_id) + log.debug("Pre at_game_start") + await self._at_game_start() # This will add votegroups to self.p_channels + log.debug("Post at_game_start") + log.debug(f"Private channels: {self.p_channels}") + for channel_id in self.p_channels.keys(): + log.debug("Setup Channel id: " + channel_id) overwrite = { self.guild.default_role: discord.PermissionOverwrite(read_messages=False), - self.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True, add_reactions=True, - manage_messages=True, manage_channels=True, - manage_roles=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"]: overwrite[player.member] = discord.PermissionOverwrite(read_messages=True) - channel = await self.guild.create_text_channel(channel_id, - overwrites=overwrite, - reason="(BOT) WW game secret channel", - category=self.channel_category) + channel = await self.guild.create_text_channel( + channel_id, + overwrites=overwrite, + reason="(BOT) WW game secret channel", + category=self.channel_category, + ) self.p_channels[channel_id]["channel"] = channel + self.to_delete.add(channel) + if self.p_channels[channel_id]["votegroup"] is not None: vote_group = self.p_channels[channel_id]["votegroup"](self, channel) @@ -208,28 +282,38 @@ class Game: self.vote_groups[channel_id] = vote_group - print("Pre-cycle") - await asyncio.sleep(1) - asyncio.ensure_future(self._cycle()) # Start the loop + log.debug("Pre-cycle") + await asyncio.sleep(0) + + asyncio.create_task(self._cycle()) # Start the loop + return True - ############START Notify structure############ + # ###########START Notify structure############ async def _cycle(self): """ - Each event calls the next event - - + Each event enqueues the next event _at_day_start() _at_voted() _at_kill() _at_day_end() - _at_night_begin() + _at_night_start() _at_night_end() - + and repeat with _at_day_start() again """ - await self._at_day_start() - # Once cycle ends, this will trigger end_game + + self.action_queue.append(self._at_day_start()) + + while self.action_queue and not self.game_over: + self.current_action = asyncio.create_task(self.action_queue.popleft()) + try: + await self.current_action + except asyncio.CancelledError: + log.debug("Cancelled task") + # + # await self._at_day_start() + # # Once cycle ends, this will trigger end_game await self._end_game() # Handle open channels async def _at_game_start(self): # ID 0 @@ -237,128 +321,156 @@ class Game: return await self.village_channel.send( - embed=discord.Embed(title="Game is starting, please wait for setup to complete")) + embed=discord.Embed(title="Game is starting, please wait for setup to complete") + ) - await self._notify(0) + await self._notify("at_game_start") async def _at_day_start(self): # ID 1 if self.game_over: return + # await self.village_channel.edit(reason="WW Night Start", name="werewolf-🌞") + self.action_queue.append(self._at_day_end()) # Get this ready in case day is cancelled + def check(): - return not self.can_vote or not self.day_time or self.game_over + return not self.any_votes_remaining or not self.day_time or self.game_over self.day_count += 1 - embed = discord.Embed(title=random.choice(self.morning_messages).format(self.day_count)) + + # Print the results of who died during the night + embed = discord.Embed(title=random.choice(self.day_start_messages).format(self.day_count)) for result in self.night_results: embed.add_field(name=result, value="________", inline=False) - self.day_time = True + self.day_time = True # True while day self.night_results = [] # Clear for next day await self.village_channel.send(embed=embed) - await self.generate_targets(self.village_channel) + await self.generate_targets(self.village_channel) # Print remaining players for voting await self.day_perms(self.village_channel) - await self._notify(1) + await self._notify("at_day_start") # Wait for day_start actions await self._check_game_over() - if self.game_over: + if self.game_over: # If game ended because of _notify return - self.can_vote = True - await asyncio.sleep(24) # 4 minute days FixMe to 120 later + self.any_votes_remaining = True + + # Now we sleep and let the day happen. Print the remaining daylight half way through + await asyncio.sleep(HALF_DAY_LENGTH) # 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(24) # 4 minute days FixMe to 120 later + await self.village_channel.send( + embed=discord.Embed(title=f"*{HALF_DAY_LENGTH / 60} minutes of daylight remain...*") + ) + await asyncio.sleep(HALF_DAY_LENGTH) # 4 minute days FixMe to 120 later - # Need a loop here to wait for trial to end (can_vote?) + # Need a loop here to wait for trial to end while self.ongoing_vote: - asyncio.sleep(5) - - if check(): - return + await asyncio.sleep(5) - await self._at_day_end() + # Abruptly ends, assuming _day_end is next in queue async def _at_voted(self, target): # ID 2 if self.game_over: return - data = {"player": target} - await self._notify(2, data) + # Notify that a target has been chosen + await self._notify("at_voted", player=target) + + # TODO: Support pre-vote target modifying roles self.ongoing_vote = True self.used_votes += 1 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)) + f"*{target.mention} will be put to trial and has 30 seconds to defend themselves**", + allowed_mentions=discord.AllowedMentions(everyone=False, users=[target]), + ) await asyncio.sleep(30) 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" + vote_message: discord.Message = await self.village_channel.send( + f"Everyone will now vote whether to lynch {target.mention}\n" "👍 to save, 👎 to lynch\n" "*Majority rules, no-lynch on ties, " - "vote both or neither to abstain, 15 seconds to vote*".format(target.mention)) + "vote both or neither to abstain, 15 seconds to vote*", + allowed_mentions=discord.AllowedMentions(everyone=False, users=[target]), + ) - await message.add_reaction("👍") - await message.add_reaction("👎") + await vote_message.add_reaction("👍") + await vote_message.add_reaction("👎") await asyncio.sleep(15) - 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) + # Refetch for reactions + vote_message = await self.village_channel.fetch_message(id=vote_message.id) + reaction_list = vote_message.reactions + + log.debug(f"Vote results: {[p.emoji.__repr__() for p in reaction_list]}") + raw_up_votes = sum(p for p in reaction_list if p.emoji == "👍" and not p.me) + raw_down_votes = sum(p for p in reaction_list if p.emoji == "👎" and not p.me) + + if True: # TODO: Allow customizable vote history deletion. + await vote_message.delete() + + # TODO: Support vote count modifying roles. (Need notify and count function) + voted_to_lynch = raw_down_votes > raw_up_votes - if down_votes > up_votes: - embed = discord.Embed(title="Vote Results", color=0xff0000) + if voted_to_lynch: + embed = discord.Embed( + title="Vote Results", + description=f"**Voted to lynch {target.mention}!**", + color=0xFF0000, + ) else: - embed = discord.Embed(title="Vote Results", color=0x80ff80) + embed = discord.Embed( + title="Vote Results", + description=f"**{target.mention} has been spared!**", + color=0x80FF80, + ) - embed.add_field(name="👎", value="**{}**".format(up_votes), inline=True) - embed.add_field(name="👍", value="**{}**".format(down_votes), inline=True) + embed.add_field(name="👎", value=f"**{raw_up_votes}**", inline=True) + embed.add_field(name="👍", value=f"**{raw_down_votes}**", inline=True) await self.village_channel.send(embed=embed) - if down_votes > up_votes: - await self.village_channel.send("**Voted to lynch {}!**".format(target.mention)) + if voted_to_lynch: await self.lynch(target) - self.can_vote = False + self.any_votes_remaining = 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 + self.any_votes_remaining = 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)) + f"**{self.used_votes}**/**{self.day_vote_count}** of today's votes have been used!\n" + "Nominate carefully.." + ) self.ongoing_vote = False - if not self.can_vote: - await self._at_day_end() + if not self.any_votes_remaining and self.day_time: + self.current_action.cancel() 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: return - data = {"player": target} - await self._notify(3, data) + await self._notify("at_kill", player=target) async def _at_hang(self, target): # ID 4 if self.game_over: return - data = {"player": target} - await self._notify(4, data) + await self._notify("at_hang", player=target) async def _at_day_end(self): # ID 5 await self._check_game_over() @@ -366,79 +478,97 @@ class Game: if self.game_over: return - self.can_vote = False + self.any_votes_remaining = False self.day_vote = {} self.vote_totals = {} self.day_time = False await self.night_perms(self.village_channel) - await self.village_channel.send(embed=discord.Embed(title="**The sun sets on the village...**")) + await self.village_channel.send( + embed=discord.Embed(title=random.choice(self.day_end_messages)) + ) - await self._notify(5) + await self._notify("at_day_end") await asyncio.sleep(5) - await self._at_night_start() + self.action_queue.append(self._at_night_start()) async def _at_night_start(self): # ID 6 if self.game_over: return - await self._notify(6) - await asyncio.sleep(12) # 2 minutes FixMe to 120 later - await self.village_channel.send(embed=discord.Embed(title="**Two minutes of night remain...**")) - await asyncio.sleep(9) # 1.5 minutes FixMe to 90 later - await self.village_channel.send(embed=discord.Embed(title="**Thirty seconds until sunrise...**")) - await asyncio.sleep(3) # .5 minutes FixMe to 3 Later + # await self.village_channel.edit(reason="WW Night Start", name="werewolf-🌑") + + await self._notify("at_night_start") + + await asyncio.sleep(HALF_NIGHT_LENGTH) # 2 minutes FixMe to 120 later + await self.village_channel.send( + embed=discord.Embed(title=f"**{HALF_NIGHT_LENGTH / 60} minutes of night remain...**") + ) + await asyncio.sleep(HALF_NIGHT_LENGTH) # 1.5 minutes FixMe to 90 later + + await asyncio.sleep(3) # .5 minutes FixMe to 30 Later - await self._at_night_end() + self.action_queue.append(self._at_night_end()) async def _at_night_end(self): # ID 7 if self.game_over: return - await self._notify(7) + await self._notify("at_night_end") await asyncio.sleep(10) - await self._at_day_start() + self.action_queue.append(self._at_day_start()) async def _at_visit(self, target, source): # ID 8 if self.game_over: return - data = {"target": target, "source": source} - await self._notify(8, data) + await self._notify("at_visit", target=target, source=source) - async def _notify(self, event, data=None): + async def _notify(self, event_name, **kwargs): for i in range(1, 7): # action guide 1-6 (0 is no action) tasks = [] - # Role priorities - role_order = [role for role in self.roles if role.action_list[event][1] == i] - for role in role_order: - tasks.append(asyncio.ensure_future(role.on_event(event, data), loop=self.loop)) - # VoteGroup priorities - vote_order = [vg for vg in self.vote_groups.values() if vg.action_list[event][1] == i] - for vote_group in vote_order: - tasks.append(asyncio.ensure_future(vote_group.on_event(event, data), loop=self.loop)) - if tasks: - await asyncio.gather(*tasks) + for event in self.listeners.get(event_name, {}).get(i, []): + tasks.append(asyncio.create_task(event(**kwargs))) + + # Run same-priority task simultaneously + await asyncio.gather(*tasks) + + # self.bot.dispatch(f"red.fox.werewolf.{event}", data=data, priority=i) + # self.bot.extra_events + # tasks = [] + # # Role priorities + # role_order = [role for role in self.roles if role.action_list[event][1] == i] + # for role in role_order: + # tasks.append(asyncio.ensure_future(role.on_event(event, data), loop=self.loop)) + # # VoteGroup priorities + # vote_order = [vg for vg in self.vote_groups.values() if vg.action_list[event][1] == i] + # for vote_group in vote_order: + # tasks.append( + # asyncio.ensure_future(vote_group.on_event(event, data), loop=self.loop) + # ) + # if tasks: + # await asyncio.gather(*tasks) # Run same-priority task simultaneously - ############END Notify structure############ + # ###########END Notify structure############ 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] + embed = discord.Embed(title="Remaining Players", description="[ID] - [Name]") + for i, player in enumerate(self.players): if player.alive: status = "" else: 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) + embed.add_field( + name=f"{i} - {status}{player.member.display_name}", + value=f"{player.role}", + inline=False, + ) else: - embed.add_field(name="ID# **{}**".format(i), - value="{}{}".format(status, player.member.display_name), - inline=True) + embed.add_field( + name=f"{i} - {status}{player.member.display_name}", inline=False, value="____" + ) return await channel.send(embed=embed) @@ -453,36 +583,46 @@ class Game: try: await asyncio.sleep(1) # This will have multiple calls self.p_channels[channel_id]["players"].append(role.player) - if votegroup is not None: - self.p_channels[channel_id]["votegroup"] = votegroup except AttributeError: continue else: break - async def join(self, member: discord.Member, channel: discord.TextChannel): + if votegroup is not None: + self.p_channels[channel_id]["votegroup"] = votegroup + + async def join(self, ctx, member: discord.Member): """ Have a member join a game """ if self.started: - await channel.send("**Game has already started!**") + await ctx.maybe_send_embed("Game has already started!") + return + + if member.bot: + await ctx.maybe_send_embed("Bots can't play games") return if await self.get_player_by_member(member) is not None: - await channel.send("{} is already in the game!".format(member.mention)) + await ctx.maybe_send_embed(f"{member.display_name} is already in the game!") return 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))) + # Add the role during setup, not before + # if self.game_role is not None: + # try: + # await member.add_roles(*[self.game_role]) + # except discord.Forbidden: + # await channel.send( + # f"Unable to add role **{self.game_role.name}**\n" + # f"Bot is missing `manage_roles` permissions" + # ) + + await ctx.maybe_send_embed( + f"{member.display_name} has been added to the game, " + f"total players is **{len(self.players)}**" + ) async def quit(self, member: discord.Member, channel: discord.TextChannel = None): """ @@ -495,11 +635,17 @@ class Game: if self.started: await self._quit(player) - await channel.send("{} has left the game".format(member.mention)) + await channel.send( + f"{member.mention} has left the game", + allowed_mentions=discord.AllowedMentions(everyone=False, users=[member]), + ) 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))) + await channel.send( + f"{member.mention} chickened out, player count is now **{len(self.players)}**", + allowed_mentions=discord.AllowedMentions(everyone=False, users=[member]), + ) async def choose(self, ctx, data): """ @@ -509,15 +655,15 @@ class Game: player = await self.get_player_by_member(ctx.author) if player is None: - await ctx.send("You're not in this game!") + await ctx.maybe_send_embed("You're not in this game!") return if not player.alive: - await ctx.send("**Corpses** can't participate...") + await ctx.maybe_send_embed("**Corpses** can't participate...") return if player.role.blocked: - await ctx.send("Something is preventing you from doing this...") + await ctx.maybe_send_embed("Something is preventing you from doing this...") return # Let role do target validation, might be alternate targets @@ -529,14 +675,14 @@ class Game: await target.role.visit(source) await self._at_visit(target, source) - async def visit(self, target_id, source): + async def visit(self, target_id, source) -> Union[Player, None]: """ Night visit target_id Returns a target for role information (i.e. Seer) """ if source.role.blocked: # Blocker handles text - return + return None target = await self.get_night_target(target_id, source) await self._visit(target, source) return target @@ -557,7 +703,7 @@ class Game: return if channel == self.village_channel: - if not self.can_vote: + if not self.any_votes_remaining: await channel.send("Voting is not allowed right now") return elif channel.name in self.p_channels: @@ -598,14 +744,16 @@ class Game: required_votes = len([player for player in self.players if player.alive]) // 7 + 2 if self.vote_totals[target_id] < required_votes: - await self.village_channel.send("" - "{} has voted to put {} to trial. " - "{} more votes needed".format(author.mention, - target.member.mention, - required_votes - self.vote_totals[target_id])) + await self.village_channel.send( + f"{author.mention} has voted to put {target.member.mention} to trial. " + f"{required_votes - self.vote_totals[target_id]} more votes needed", + allowed_mentions=discord.AllowedMentions(everyone=False, users=[author, target]), + ) else: self.vote_totals[target_id] = 0 - self.day_vote = {k: v for k, v in self.day_vote.items() if v != target_id} # Remove votes for this id + self.day_vote = { + k: v for k, v in self.day_vote.items() if v != target_id + } # Remove votes for this id await self._at_voted(target) async def eval_results(self, target, source=None, method=None): @@ -613,9 +761,9 @@ class Game: out = "**{ID}** - " + method return out.format(ID=target.id, target=target.member.display_name) else: - return "**{ID}** - {target} the {role} was found dead".format(ID=target.id, - target=target.member.display_name, - role=await target.role.get_role()) + 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): """ @@ -668,22 +816,22 @@ class Game: Attempt to lynch a target Important to finish execution before triggering notify """ - target = await self.get_day_target(target_id) - target.alive = False + target = await self.get_day_target(target_id) # Allows target modification + target.alive = False # Kill them, await self._at_hang(target) if not target.alive: # Still dead after notifying await self.dead_perms(self.village_channel, target.member) - async def get_night_target(self, target_id, source=None): + async def get_night_target(self, target_id, source=None) -> Player: return self.players[target_id] # ToDo check source - async def get_day_target(self, target_id, source=None): + async def get_day_target(self, target_id, source=None) -> Player: return self.players[target_id] # ToDo check source 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") + await ctx.maybe_send_embed("Code has been set") async def get_roles(self, ctx, game_code=None): if game_code is not None: @@ -695,10 +843,12 @@ class Game: 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)) + await ctx.maybe_send_embed( + "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)) + await ctx.maybe_send_embed("Invalid Code: Code references unknown role\n{}".format(e)) if not self.roles: return False @@ -717,7 +867,7 @@ class Game: # Sorted players, now assign id's await self.players[idx].assign_id(idx) - async def get_player_by_member(self, member): + async def get_player_by_member(self, member: discord.Member): for player in self.players: if player.member == member: return player @@ -748,7 +898,9 @@ class Game: 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!**")) + 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 @@ -758,7 +910,8 @@ class Game: self.game_over = True alignment1 = alive_players[0].role.alignment alignment2 = alive_players[1].role.alignment - if alignment1 == alignment2: # Same team + # Same team and not neutral + if alignment1 == alignment2 and alignment1 != ALIGNMENT_NEUTRAL: winners = alive_players else: winners = [max(alive_players, key=lambda p: p.role.alignment)] @@ -776,31 +929,105 @@ class Game: await self._announce_winners(alive_players) # If no return, cleanup and end game - await self._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!') + 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') + 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): # Remove game_role access for potential archiving for now - reason = '(BOT) End of WW game' + reason = "(BOT) End of WW game" for obj in self.to_delete: - print(obj) - await obj.delete(reason=reason) + log.debug(f"End_game: Deleting object {obj.__repr__()}") + try: + await obj.delete(reason=reason) + except discord.NotFound: + # Already deleted + pass 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) + asyncio.create_task(self.village_channel.edit(reason=reason, name="werewolf")) + async for channel, overwrites in AsyncIter(self.save_perms.items()): + async for target, overwrite in AsyncIter(overwrites.items()): + await channel.set_permissions(target, overwrite=overwrite, reason=reason) + # 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 + for player in self.players: + try: + await player.member.remove_roles(*[self.game_role]) + except discord.Forbidden: + log.exception(f"Unable to add remove **{self.game_role.name}**") + # await ctx.send( + # f"Unable to add role **{self.game_role.name}**\n" + # f"Bot is missing `manage_roles` permissions" + # ) + pass + # Optional dynamic channels/categories + + def add_ww_listener(self, func, priority=0, name=None): + """Adds a listener from the pool of listeners. + + Parameters + ----------- + func: :ref:`coroutine ` + The function to call. + priority: Optional[:class:`int`] + Priority of the listener. Defaults to 0 (no-action) + name: Optional[:class:`str`] + The name of the event to listen for. Defaults to ``func.__name__``. + do_sort: Optional[:class:`bool`] + Whether or not to sort listeners after. Skip sorting during mass appending + + """ + name = func.__name__ if name is None else name + + if not asyncio.iscoroutinefunction(func): + raise TypeError("Listeners must be coroutines") + + if name in self.listeners: + if priority in self.listeners[name]: + self.listeners[name][priority].append(func) + else: + self.listeners[name][priority] = [func] + else: + self.listeners[name] = {priority: [func]} + + # self.listeners[name].sort(reverse=True) + + # def remove_wolf_listener(self, func, name=None): + # """Removes a listener from the pool of listeners. + # + # Parameters + # ----------- + # func + # The function that was used as a listener to remove. + # name: :class:`str` + # The name of the event we want to remove. Defaults to + # ``func.__name__``. + # """ + # + # name = func.__name__ if name is None else name + # + # if name in self.listeners: + # try: + # self.listeners[name].remove(func) + # except ValueError: + # pass diff --git a/werewolf/info.json b/werewolf/info.json index 99bc768..c8ef454 100644 --- a/werewolf/info.json +++ b/werewolf/info.json @@ -4,10 +4,10 @@ ], "min_bot_version": "3.3.0", "description": "Customizable Werewolf Game", - "hidden": true, + "hidden": false, "install_msg": "Thank you for installing Werewolf! Get started with `[p]load werewolf`\n Use `[p]wwset` to run inital setup", "requirements": [], - "short": "Werewolf Game", + "short": "[ALPHA] Play Werewolf (Mafia) Game in discord", "end_user_data_statement": "This stores user IDs in memory while they're actively using the cog, and stores no persistent End User Data.", "tags": [ "mafia", diff --git a/werewolf/listener.py b/werewolf/listener.py new file mode 100644 index 0000000..29ef7dd --- /dev/null +++ b/werewolf/listener.py @@ -0,0 +1,106 @@ +import inspect + + +def wolflistener(name=None, priority=0): + """A decorator that marks a function as a listener. + + This is the werewolf.Game equivalent of :meth:`.Cog.listener`. + + Parameters + ------------ + name: :class:`str` + The name of the event being listened to. If not provided, it + defaults to the function's name. + priority: :class:`int` + The priority of the listener. + Priority guide as follows: + _at_night_start + 0. No Action + 1. Detain actions (Jailer/Kidnapper) + 2. Group discussions and choose targets + + _at_night_end + 0. No Action + 1. Self actions (Veteran) + 2. Target switching and role blocks (bus driver, witch, escort) + 3. Protection / Preempt actions (bodyguard/framer) + 4. Non-disruptive actions (seer/silencer) + 5. Disruptive actions (Killing) + 6. Role altering actions (Cult / Mason / Shifter) + + Raises + -------- + TypeError + The function is not a coroutine function or a string was not passed as + the name. + """ + + if name is not None and not isinstance(name, str): + raise TypeError( + "Game.listener expected str but received {0.__class__.__name__!r} instead.".format( + name + ) + ) + + def decorator(func): + actual = func + if isinstance(actual, staticmethod): + actual = actual.__func__ + if not inspect.iscoroutinefunction(actual): + raise TypeError("Listener function must be a coroutine function.") + actual.__wolf_listener__ = priority + to_assign = name or actual.__name__ + try: + actual.__wolf_listener_names__.append((priority, to_assign)) + except AttributeError: + actual.__wolf_listener_names__ = [(priority, to_assign)] + # we have to return `func` instead of `actual` because + # we need the type to be `staticmethod` for the metaclass + # to pick it up but the metaclass unfurls the function and + # thus the assignments need to be on the actual function + return func + + return decorator + + +class WolfListenerMeta(type): + def __new__(mcs, *args, **kwargs): + name, bases, attrs = args + + listeners = {} + need_at_msg = "Listeners must start with at_ (in method {0.__name__}.{1})" + + new_cls = super().__new__(mcs, name, bases, attrs, **kwargs) + for base in reversed(new_cls.__mro__): + for elem, value in base.__dict__.items(): + if elem in listeners: + del listeners[elem] + + is_static_method = isinstance(value, staticmethod) + if is_static_method: + value = value.__func__ + if inspect.iscoroutinefunction(value): + try: + is_listener = getattr(value, "__wolf_listener__") + except AttributeError: + continue + else: + # if not elem.startswith("at_"): + # raise TypeError(need_at_msg.format(base, elem)) + listeners[elem] = value + + listeners_as_list = [] + for listener in listeners.values(): + for priority, listener_name in listener.__wolf_listener_names__: + # I use __name__ instead of just storing the value so I can inject + # the self attribute when the time comes to add them to the bot + listeners_as_list.append((priority, listener_name, listener.__name__)) + + new_cls.__wolf_listeners__ = listeners_as_list + return new_cls + + +class WolfListener(metaclass=WolfListenerMeta): + def __init__(self, game): + for priority, name, method_name in self.__wolf_listeners__: + game.add_ww_listener(getattr(self, method_name), priority, name) diff --git a/werewolf/night_powers.py b/werewolf/night_powers.py index b50929b..ab82e87 100644 --- a/werewolf/night_powers.py +++ b/werewolf/night_powers.py @@ -1,4 +1,8 @@ -from .role import Role +import logging + +from werewolf.role import Role + +log = logging.getLogger("red.fox_v3.werewolf.night_powers") def night_immune(role: Role): diff --git a/werewolf/player.py b/werewolf/player.py index c84d87f..c574109 100644 --- a/werewolf/player.py +++ b/werewolf/player.py @@ -1,5 +1,9 @@ +import logging + import discord +log = logging.getLogger("red.fox_v3.werewolf.player") + class Player: """ @@ -16,6 +20,9 @@ class Player: self.muted = False self.protected = False + def __repr__(self): + return f"{self.__class__.__name__}({self.member})" + async def assign_role(self, role): """ Give this player a role @@ -28,6 +35,15 @@ class Player: async def send_dm(self, message): try: - await self.member.send(message) # Lets do embeds later + await self.member.send(message) # Lets ToDo embeds later except discord.Forbidden: - await self.role.game.village_channel.send("Couldn't DM {}, uh oh".format(self.mention)) + log.info(f"Unable to mention {self.member.__repr__()}") + await self.role.game.village_channel.send( + f"Couldn't DM {self.mention}, uh oh", + allowed_mentions=discord.AllowedMentions(users=[self.member]), + ) + except AttributeError: + log.exception("Someone messed up and added a bot to the game (I think)") + await self.role.game.village_channel.send( + "Someone messed up and added a bot to the game :eyes:" + ) diff --git a/werewolf/role.py b/werewolf/role.py index 3e4124d..e267283 100644 --- a/werewolf/role.py +++ b/werewolf/role.py @@ -1,31 +1,41 @@ -class Role: +import inspect +import logging + +from werewolf.listener import WolfListener, wolflistener + +log = logging.getLogger("red.fox_v3.werewolf.role") + + +class Role(WolfListener): """ Base Role class for werewolf game - + Category enrollment guide as follows (category property): Town: 1: Random, 2: Investigative, 3: Protective, 4: Government, 5: Killing, 6: Power (Special night action) - + Werewolf: 11: Random, 12: Deception, 15: Killing, 16: Support - + Neutral: 21: Benign, 22: Evil, 23: Killing - - + + Example category: category = [1, 5, 6] Could be Veteran category = [1, 5] Could be Bodyguard category = [11, 16] Could be Werewolf Silencer - - - Action guide as follows (on_event function): + category = [22] Could be Blob (non-killing) + category = [22, 23] Could be Serial-Killer + + + Action priority guide as follows (on_event function): _at_night_start 0. No Action 1. Detain actions (Jailer/Kidnapper) 2. Group discussions and choose targets - + _at_night_end 0. No Action 1. Self actions (Veteran) @@ -33,13 +43,15 @@ class Role: 3. Protection / Preempt actions (bodyguard/framer) 4. Non-disruptive actions (seer/silencer) 5. Disruptive actions (Killing) - 6. Role altering actions (Cult / Mason) + 6. Role altering actions (Cult / Mason / Shifter) """ - rand_choice = False # Determines if it can be picked as a random role (False for unusually disruptive roles) + # Determines if it can be picked as a random role (False for unusually disruptive roles) + rand_choice = False # TODO: Rework random with categories + town_balance = 0 # Guess at power level and it's balance on the town category = [0] # List of enrolled categories (listed above) alignment = 0 # 1: Town, 2: Werewolf, 3: Neutral - channel_id = "" # Empty for no private channel + channel_name = "" # Empty for no private channel unique = False # Only one of this role per game game_start_message = ( "Your role is **Default**\n" @@ -54,32 +66,14 @@ class Role: icon_url = None # Adding a URL here will enable a thumbnail of the role def __init__(self, game): + super().__init__(game) self.game = game self.player = None self.blocked = False self.properties = {} # Extra data for other roles (i.e. arsonist) - self.action_list = [ - (self._at_game_start, 1), # (Action, Priority) - (self._at_day_start, 0), - (self._at_voted, 0), - (self._at_kill, 0), - (self._at_hang, 0), - (self._at_day_end, 0), - (self._at_night_start, 0), - (self._at_night_end, 0), - (self._at_visit, 0) - ] - def __repr__(self): - return self.__class__.__name__ - - async def on_event(self, event, data): - """ - See Game class for event guide - """ - - await self.action_list[event][0](data) + return f"{self.__class__.__name__}({self.player.__repr__()})" async def assign_player(self, player): """ @@ -90,6 +84,8 @@ class Role: player.role = self self.player = player + log.debug(f"Assigned {self} to {player}") + async def get_alignment(self, source=None): """ Interaction for powerful access of alignment @@ -101,7 +97,7 @@ class Role: async def see_alignment(self, source=None): """ Interaction for investigative roles attempting - to see alignment (Village, Werewolf Other) + to see alignment (Village, Werewolf, Other) """ return "Other" @@ -119,35 +115,16 @@ class Role: """ return "Default" - async def _at_game_start(self, data=None): - if self.channel_id: - await self.game.register_channel(self.channel_id, self) - - await self.player.send_dm(self.game_start_message) # Maybe embeds eventually + @wolflistener("at_game_start", priority=2) + async def _at_game_start(self): + if self.channel_name: + await self.game.register_channel(self.channel_name, self) - async def _at_day_start(self, data=None): - pass - - async def _at_voted(self, data=None): - pass - - async def _at_kill(self, data=None): - pass - - async def _at_hang(self, data=None): - pass - - async def _at_day_end(self, data=None): - pass - - async def _at_night_start(self, data=None): - pass - - async def _at_night_end(self, data=None): - pass - - async def _at_visit(self, data=None): - pass + try: + await self.player.send_dm(self.game_start_message) # Maybe embeds eventually + except AttributeError as e: + log.exception(self.__repr__()) + raise e async def kill(self, source): """ diff --git a/werewolf/roles/__init__.py b/werewolf/roles/__init__.py new file mode 100644 index 0000000..3f58a76 --- /dev/null +++ b/werewolf/roles/__init__.py @@ -0,0 +1,11 @@ +from .villager import Villager +from .seer import Seer + +from .vanillawerewolf import VanillaWerewolf + +from .shifter import Shifter + +# Don't sort these imports. They are unstably in order +# TODO: Replace with unique IDs for roles in the future + +__all__ = ["Seer", "Shifter", "VanillaWerewolf", "Villager"] diff --git a/werewolf/roles/blob.py b/werewolf/roles/blob.py new file mode 100644 index 0000000..af18983 --- /dev/null +++ b/werewolf/roles/blob.py @@ -0,0 +1,101 @@ +import logging +import random + +from werewolf.constants import ALIGNMENT_NEUTRAL, CATEGORY_NEUTRAL_EVIL +from werewolf.listener import wolflistener +from werewolf.player import Player +from werewolf.role import Role + +log = logging.getLogger("red.fox_v3.werewolf.role.blob") + + +class TheBlob(Role): + rand_choice = True + category = [CATEGORY_NEUTRAL_EVIL] # List of enrolled categories + alignment = ALIGNMENT_NEUTRAL # 1: Town, 2: Werewolf, 3: Neutral + channel_id = "" # Empty for no private channel + unique = True # Only one of this role per game + game_start_message = ( + "Your role is **The Blob**\n" + "You win by absorbing everyone town\n" + "Lynch players during the day with `[p]ww vote `\n" + "Each night you will absorb an adjacent player" + ) + description = ( + "A mysterious green blob of jelly, slowly growing in size.\n" + "The Blob fears no evil, must be dealt with in town" + ) + + def __init__(self, game): + super().__init__(game) + + self.blob_target = None + + async def see_alignment(self, source=None): + """ + Interaction for investigative roles attempting + to see team (Village, Werewolf, Other) + """ + return ALIGNMENT_NEUTRAL + + async def get_role(self, source=None): + """ + Interaction for powerful access of role + Unlikely to be able to deceive this + """ + return "The Blob" + + async def see_role(self, source=None): + """ + Interaction for investigative roles. + More common to be able to deceive these roles + """ + return "The Blob" + + async def kill(self, source): + """ + Called when someone is trying to kill you! + Can you do anything about it? + self.player.alive is now set to False, set to True to stay alive + """ + + # Blob cannot simply be killed + self.player.alive = True + + @wolflistener("at_night_start", priority=2) + async def _at_night_start(self): + if not self.player.alive: + return + + self.blob_target = None + idx = self.player.id + left_or_right = random.choice((-1, 1)) + while self.blob_target is None: + idx += left_or_right + if idx >= len(self.game.players): + idx = 0 + + player = self.game.players[idx] + + # you went full circle, everyone is a blob or something else is wrong + if player == self.player: + break + + if player.role.properties.get("been_blobbed", False): + self.blob_target = player + + if self.blob_target is not None: + await self.player.send_dm(f"**You will attempt to absorb {self.blob_target} tonight**") + else: + await self.player.send_dm(f"**No player will be absorbed tonight**") + + @wolflistener("at_night_end", priority=4) + async def _at_night_end(self): + if self.blob_target is None or not self.player.alive: + return + + target: "Player" = await self.game.visit(self.blob_target, self.player) + + if target is not None: + target.role.properties["been_blobbed"] = True + self.game.night_results.append("The Blob grows...") diff --git a/werewolf/roles/seer.py b/werewolf/roles/seer.py index 35c8271..983fd14 100644 --- a/werewolf/roles/seer.py +++ b/werewolf/roles/seer.py @@ -1,11 +1,26 @@ -from ..night_powers import pick_target -from ..role import Role +import logging + +from werewolf.constants import ( + ALIGNMENT_TOWN, + ALIGNMENT_WEREWOLF, + CATEGORY_TOWN_INVESTIGATIVE, + CATEGORY_TOWN_RANDOM, +) +from werewolf.listener import wolflistener +from werewolf.night_powers import pick_target +from werewolf.role import Role + +log = logging.getLogger("red.fox_v3.werewolf.role.seer") class Seer(Role): - 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 + rand_choice = True + town_balance = 4 + category = [ + CATEGORY_TOWN_RANDOM, + CATEGORY_TOWN_INVESTIGATIVE, + ] # List of enrolled categories (listed above) + alignment = ALIGNMENT_TOWN # 1: Town, 2: Werewolf, 3: Neutral channel_id = "" # Empty for no private channel unique = False # Only one of this role per game game_start_message = ( @@ -14,8 +29,10 @@ class Seer(Role): "Lynch players during the day with `[p]ww vote `\n" "Check for werewolves at night with `[p]ww choose `" ) - description = "A mystic in search of answers in a chaotic town.\n" \ - "Calls upon the cosmos to discern those of Lycan blood" + description = ( + "A mystic in search of answers in a chaotic town.\n" + "Calls upon the cosmos to discern those of Lycan blood" + ) def __init__(self, game): super().__init__(game) @@ -24,47 +41,49 @@ class Seer(Role): # self.blocked = False # self.properties = {} # Extra data for other roles (i.e. arsonist) self.see_target = None - self.action_list = [ - (self._at_game_start, 1), # (Action, Priority) - (self._at_day_start, 0), - (self._at_voted, 0), - (self._at_kill, 0), - (self._at_hang, 0), - (self._at_day_end, 0), - (self._at_night_start, 2), - (self._at_night_end, 4), - (self._at_visit, 0) - ] + # self.action_list = [ + # (self._at_game_start, 1), # (Action, Priority) + # (self._at_day_start, 0), + # (self._at_voted, 0), + # (self._at_kill, 0), + # (self._at_hang, 0), + # (self._at_day_end, 0), + # (self._at_night_start, 2), + # (self._at_night_end, 4), + # (self._at_visit, 0), + # ] async def see_alignment(self, source=None): """ Interaction for investigative roles attempting - to see team (Village, Werewolf Other) + to see team (Village, Werewolf, Other) """ - return "Village" + return ALIGNMENT_TOWN async def get_role(self, source=None): """ Interaction for powerful access of role Unlikely to be able to deceive this """ - return "Villager" + return "Seer" async def see_role(self, source=None): """ Interaction for investigative roles. More common to be able to deceive these roles """ - return "Villager" + return "Seer" - async def _at_night_start(self, data=None): + @wolflistener("at_night_start", priority=2) + async def _at_night_start(self): if not self.player.alive: return self.see_target = None await self.game.generate_targets(self.player.member) await self.player.send_dm("**Pick a target to see tonight**") - async def _at_night_end(self, data=None): + @wolflistener("at_night_end", priority=4) + async def _at_night_end(self): if self.see_target is None: if self.player.alive: await self.player.send_dm("You will not use your powers tonight...") @@ -75,9 +94,9 @@ class Seer(Role): if target: alignment = await target.role.see_alignment(self.player) - if alignment == "Werewolf": + if alignment == ALIGNMENT_WEREWOLF: out = "Your insight reveals this player to be a **Werewolf!**" - else: + else: # Don't reveal neutrals out = "You fail to find anything suspicious about this player..." await self.player.send_dm(out) @@ -87,4 +106,6 @@ class Seer(Role): await super().choose(ctx, data) self.see_target, target = await pick_target(self, ctx, data) - await ctx.send("**You will attempt to see the role of {} tonight...**".format(target.member.display_name)) + await ctx.send( + f"**You will attempt to see the role of {target.member.display_name} tonight...**" + ) diff --git a/werewolf/roles/shifter.py b/werewolf/roles/shifter.py index 4c550dc..9685e20 100644 --- a/werewolf/roles/shifter.py +++ b/werewolf/roles/shifter.py @@ -1,35 +1,41 @@ -from ..night_powers import pick_target -from ..role import Role +import logging + +from werewolf.constants import ALIGNMENT_NEUTRAL, CATEGORY_NEUTRAL_BENIGN +from werewolf.listener import wolflistener +from werewolf.night_powers import pick_target +from werewolf.role import Role + +log = logging.getLogger("red.fox_v3.werewolf.role.shifter") class Shifter(Role): """ Base Role class for werewolf game - + Category enrollment guide as follows (category property): Town: 1: Random, 2: Investigative, 3: Protective, 4: Government, 5: Killing, 6: Power (Special night action) - + Werewolf: 11: Random, 12: Deception, 15: Killing, 16: Support - + Neutral: 21: Benign, 22: Evil, 23: Killing - - + + Example category: category = [1, 5, 6] Could be Veteran category = [1, 5] Could be Bodyguard category = [11, 16] Could be Werewolf Silencer - - + + Action guide as follows (on_event function): _at_night_start 0. No Action 1. Detain actions (Jailer/Kidnapper) 2. Group discussions and choose targets - + _at_night_end 0. No Action 1. Self actions (Veteran) @@ -37,12 +43,13 @@ class Shifter(Role): 3. Protection / Preempt actions (bodyguard/framer) 4. Non-disruptive actions (seer/silencer) 5. Disruptive actions (Killing) - 6. Role altering actions (Cult / Mason) + 6. Role altering actions (Cult / Mason / Shifter) """ rand_choice = False # Determines if it can be picked as a random role (False for unusually disruptive roles) - category = [22] # List of enrolled categories (listed above) - alignment = 3 # 1: Town, 2: Werewolf, 3: Neutral + town_balance = -3 + category = [CATEGORY_NEUTRAL_BENIGN] # List of enrolled categories (listed above) + alignment = ALIGNMENT_NEUTRAL # 1: Town, 2: Werewolf, 3: Neutral channel_id = "" # Empty for no private channel unique = False # Only one of this role per game game_start_message = ( @@ -61,22 +68,22 @@ class Shifter(Role): super().__init__(game) self.shift_target = None - self.action_list = [ - (self._at_game_start, 1), # (Action, Priority) - (self._at_day_start, 0), - (self._at_voted, 0), - (self._at_kill, 0), - (self._at_hang, 0), - (self._at_day_end, 0), - (self._at_night_start, 2), # Chooses targets - (self._at_night_end, 6), # Role Swap - (self._at_visit, 0) - ] + # self.action_list = [ + # (self._at_game_start, 1), # (Action, Priority) + # (self._at_day_start, 0), + # (self._at_voted, 0), + # (self._at_kill, 0), + # (self._at_hang, 0), + # (self._at_day_end, 0), + # (self._at_night_start, 2), # Chooses targets + # (self._at_night_end, 6), # Role Swap + # (self._at_visit, 0), + # ] async def see_alignment(self, source=None): """ Interaction for investigative roles attempting - to see alignment (Village, Werewolf, Other) + to see alignment (Village, Werewolf,, Other) """ return "Other" @@ -94,14 +101,14 @@ class Shifter(Role): """ return "Shifter" - async def _at_night_start(self, data=None): - await super()._at_night_start(data) + @wolflistener("at_night_start", priority=2) + async def _at_night_start(self): self.shift_target = None await self.game.generate_targets(self.player.member) await self.player.send_dm("**Pick a target to shift into**") - async def _at_night_end(self, data=None): - await super()._at_night_end(data) + @wolflistener("at_night_end", priority=6) + async def _at_night_end(self): if self.shift_target is None: if self.player.alive: await self.player.send_dm("You will not use your powers tonight...") @@ -114,16 +121,20 @@ class Shifter(Role): # Roles have now been swapped - await self.player.send_dm("Your role has been stolen...\n" - "You are now a **Shifter**.") + await self.player.send_dm( + "Your role has been stolen...\n" "You are now a **Shifter**." + ) await self.player.send_dm(self.game_start_message) await target.send_dm(target.role.game_start_message) else: await self.player.send_dm("**Your shift failed...**") + async def choose(self, ctx, data): """Handle night actions""" await super().choose(ctx, data) self.shift_target, target = await pick_target(self, ctx, data) - await ctx.send("**You will attempt to see the role of {} tonight...**".format(target.member.display_name)) + await ctx.send( + f"**You will attempt to see the role of {target.member.display_name} tonight...**" + ) diff --git a/werewolf/roles/vanillawerewolf.py b/werewolf/roles/vanillawerewolf.py index c8050da..8abdea2 100644 --- a/werewolf/roles/vanillawerewolf.py +++ b/werewolf/roles/vanillawerewolf.py @@ -1,13 +1,19 @@ -from ..role import Role +import logging -from ..votegroups.wolfvote import WolfVote +from werewolf.constants import ALIGNMENT_WEREWOLF, CATEGORY_WW_KILLING, CATEGORY_WW_RANDOM +from werewolf.listener import wolflistener +from werewolf.role import Role +from werewolf.votegroups.wolfvote import WolfVote + +log = logging.getLogger("red.fox_v3.werewolf.role.vanillawerewolf") class VanillaWerewolf(Role): rand_choice = True - category = [11, 15] - alignment = 2 # 1: Town, 2: Werewolf, 3: Neutral - channel_id = "werewolves" + town_balance = -6 + category = [CATEGORY_WW_RANDOM, CATEGORY_WW_KILLING] + alignment = ALIGNMENT_WEREWOLF # 1: Town, 2: Werewolf, 3: Neutral + channel_name = "werewolves" unique = False game_start_message = ( "Your role is **Werewolf**\n" @@ -16,34 +22,19 @@ class VanillaWerewolf(Role): "Vote to kill players at night with `[p]ww vote `" ) - def __init__(self, game): - super().__init__(game) - - self.action_list = [ - (self._at_game_start, 1), # (Action, Priority) - (self._at_day_start, 0), - (self._at_voted, 0), - (self._at_kill, 0), - (self._at_hang, 0), - (self._at_day_end, 0), - (self._at_night_start, 0), - (self._at_night_end, 0), - (self._at_visit, 0) - ] - async def see_alignment(self, source=None): """ Interaction for investigative roles attempting to see team (Village, Werewolf Other) """ - return "Werewolf" + return ALIGNMENT_WEREWOLF async def get_role(self, source=None): """ Interaction for powerful access of role Unlikely to be able to deceive this """ - return "Werewolf" + return "VanillaWerewolf" async def see_role(self, source=None): """ @@ -52,10 +43,13 @@ class VanillaWerewolf(Role): """ return "Werewolf" - async def _at_game_start(self, data=None): - if self.channel_id: - print("Wolf has channel_id: " + self.channel_id) - await self.game.register_channel(self.channel_id, self, WolfVote) # Add VoteGroup WolfVote + @wolflistener("at_game_start", priority=2) + async def _at_game_start(self): + if self.channel_name: + log.debug("Wolf has channel_name: " + self.channel_name) + await self.game.register_channel( + self.channel_name, self, WolfVote + ) # Add VoteGroup WolfVote await self.player.send_dm(self.game_start_message) diff --git a/werewolf/roles/villager.py b/werewolf/roles/villager.py index bda51d2..eb0b2c9 100644 --- a/werewolf/roles/villager.py +++ b/werewolf/roles/villager.py @@ -1,10 +1,17 @@ -from ..role import Role +import logging + +from werewolf.constants import ALIGNMENT_TOWN, CATEGORY_TOWN_RANDOM +from werewolf.role import Role + +log = logging.getLogger("red.fox_v3.werewolf.role.villager") class Villager(Role): - 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 + # Determines if it can be picked as a random role (False for unusually disruptive roles) + rand_choice = True + town_balance = 1 + category = [CATEGORY_TOWN_RANDOM] # List of enrolled categories (listed above) + alignment = ALIGNMENT_TOWN # 1: Town, 2: Werewolf, 3: Neutral channel_id = "" # Empty for no private channel unique = False # Only one of this role per game game_start_message = ( @@ -13,15 +20,12 @@ class Villager(Role): "Lynch players during the day with `[p]ww vote `" ) - def __init__(self, game): - super().__init__(game) - async def see_alignment(self, source=None): """ Interaction for investigative roles attempting - to see team (Village, Werewolf Other) + to see team (Village, Werewolf, Other) """ - return "Village" + return ALIGNMENT_TOWN async def get_role(self, source=None): """ diff --git a/werewolf/votegroup.py b/werewolf/votegroup.py index bf07c8c..e651eda 100644 --- a/werewolf/votegroup.py +++ b/werewolf/votegroup.py @@ -1,4 +1,11 @@ -class VoteGroup: +import logging + +from werewolf.listener import WolfListener, wolflistener + +log = logging.getLogger("red.fox_v3.werewolf.votegroup") + + +class VoteGroup(WolfListener): """ Base VoteGroup class for werewolf game Handles secret channels and group decisions @@ -8,58 +15,41 @@ class VoteGroup: channel_id = "" def __init__(self, game, channel): + super().__init__(game) self.game = game self.channel = channel self.players = [] self.vote_results = {} self.properties = {} # Extra data for other options - self.action_list = [ - (self._at_game_start, 1), # (Action, Priority) - (self._at_day_start, 0), - (self._at_voted, 0), - (self._at_kill, 1), - (self._at_hang, 1), - (self._at_day_end, 0), - (self._at_night_start, 2), - (self._at_night_end, 0), - (self._at_visit, 0) - ] - - async def on_event(self, event, data): - """ - See Game class for event guide - """ + def __repr__(self): + return f"{self.__class__.__name__}({self.channel},{self.players})" - await self.action_list[event][0](data) - - async def _at_game_start(self, data=None): + @wolflistener("at_game_start", priority=1) + async def _at_game_start(self): await self.channel.send(" ".join(player.mention for player in self.players)) - async def _at_day_start(self, data=None): - pass - - async def _at_voted(self, data=None): - pass - - async def _at_kill(self, data=None): - if data["player"] in self.players: - self.players.remove(data["player"]) - - async def _at_hang(self, data=None): - if data["player"] in self.players: - self.players.remove(data["player"]) + @wolflistener("at_kill", priority=1) + async def _at_kill(self, player): + if player in self.players: + self.players.remove(player) - async def _at_day_end(self, data=None): - pass + @wolflistener("at_hang", priority=1) + async def _at_hang(self, player): + if player in self.players: + self.players.remove(player) - async def _at_night_start(self, data=None): + @wolflistener("at_night_start", priority=2) + async def _at_night_start(self): if self.channel is None: return + self.vote_results = {} + await self.game.generate_targets(self.channel) - async def _at_night_end(self, data=None): + @wolflistener("at_night_end", priority=5) + async def _at_night_end(self): if self.channel is None: return @@ -70,11 +60,8 @@ class VoteGroup: target = max(set(vote_list), key=vote_list.count) if target: - # Do what you voted on - pass - - async def _at_visit(self, data=None): - pass + # Do what the votegroup votes on + raise NotImplementedError async def register_players(self, *players): """ @@ -90,7 +77,7 @@ class VoteGroup: self.players.remove(player) if not self.players: - # ToDo: Trigger deletion of votegroup + # TODO: Confirm deletion pass async def vote(self, target, author, target_id): diff --git a/werewolf/votegroups/__init__.py b/werewolf/votegroups/__init__.py new file mode 100644 index 0000000..6b99b1e --- /dev/null +++ b/werewolf/votegroups/__init__.py @@ -0,0 +1 @@ +from .wolfvote import WolfVote diff --git a/werewolf/votegroups/wolfvote.py b/werewolf/votegroups/wolfvote.py index 9c068d5..dfb4f32 100644 --- a/werewolf/votegroups/wolfvote.py +++ b/werewolf/votegroups/wolfvote.py @@ -1,6 +1,12 @@ +import logging import random -from ..votegroup import VoteGroup +import discord + +from werewolf.listener import wolflistener +from werewolf.votegroup import VoteGroup + +log = logging.getLogger("red.fox_v3.werewolf.votegroup.wolfvote") class WolfVote(VoteGroup): @@ -13,71 +19,29 @@ class WolfVote(VoteGroup): kill_messages = [ "**{ID}** - {target} was mauled by wolves", - "**{ID}** - {target} was found torn to shreds"] + "**{ID}** - {target} was found torn to shreds", + ] def __init__(self, game, channel): super().__init__(game, channel) - # self.game = game - # self.channel = channel - # self.players = [] - # self.vote_results = {} - # self.properties = {} # Extra data for other options self.killer = None # Added killer - self.action_list = [ - (self._at_game_start, 1), # (Action, Priority) - (self._at_day_start, 0), - (self._at_voted, 0), - (self._at_kill, 1), - (self._at_hang, 1), - (self._at_day_end, 0), - (self._at_night_start, 2), - (self._at_night_end, 5), # Kill priority - (self._at_visit, 0) - ] - - # async def on_event(self, event, data): - - # """ - # See Game class for event guide - # """ - # - # await action_list[event][0](data) - # - # async def _at_game_start(self, data=None): - # await self.channel.send(" ".join(player.mention for player in self.players)) - # - # async def _at_day_start(self, data=None): - # pass - # - # async def _at_voted(self, data=None): - # pass - # - # async def _at_kill(self, data=None): - # if data["player"] in self.players: - # self.players.pop(data["player"]) - # - # async def _at_hang(self, data=None): - # if data["player"] in self.players: - # self.players.pop(data["player"]) - # - # async def _at_day_end(self, data=None): - # pass - - async def _at_night_start(self, data=None): - if self.channel is None: - return + @wolflistener("at_night_start", priority=2) + async def _at_night_start(self): + await super()._at_night_start() - await self.game.generate_targets(self.channel) mention_list = " ".join(player.mention for player in self.players) if mention_list != "": await self.channel.send(mention_list) self.killer = random.choice(self.players) - await self.channel.send("{} has been selected as tonight's killer".format(self.killer.member.display_name)) + await self.channel.send( + f"{self.killer.member.display_name} has been selected as tonight's killer" + ) - async def _at_night_end(self, data=None): + @wolflistener("at_night_end", priority=5) + async def _at_night_end(self): if self.channel is None: return @@ -87,34 +51,23 @@ class WolfVote(VoteGroup): if vote_list: target_id = max(set(vote_list), key=vote_list.count) - print("Target id: {}\nKiller: {}".format(target_id, self.killer.member.display_name)) + log.debug(f"Target id: {target_id}\nKiller: {self.killer.member.display_name}") if target_id is not None and self.killer: await self.game.kill(target_id, self.killer, random.choice(self.kill_messages)) - await self.channel.send("**{} has left to complete the kill...**".format(self.killer.member.display_name)) + await self.channel.send( + "*{} has left to complete the kill...*".format(self.killer.member.display_name) + ) else: - await self.channel.send("**No kill will be attempted tonight...**") - - # async def _at_visit(self, data=None): - # pass - # - # async def register_players(self, *players): - # """ - # Extend players by passed list - # """ - # self.players.extend(players) - # - # async def remove_player(self, player): - # """ - # Remove a player from player list - # """ - # if player.id in self.players: - # self.players.remove(player) + await self.channel.send("*No kill will be attempted tonight...*") async def vote(self, target, author, target_id): """ Receive vote from game """ - self.vote_results[author.id] = target_id + await super().vote(target, author, target_id) - await self.channel.send("{} has voted to kill {}".format(author.mention, target.member.display_name)) + await self.channel.send( + "{} has voted to kill {}".format(author.mention, target.member.display_name), + allowed_mentions=discord.AllowedMentions(everyone=False, users=[author]), + ) diff --git a/werewolf/werewolf.py b/werewolf/werewolf.py index 1f8fc3f..bd68a6f 100644 --- a/werewolf/werewolf.py +++ b/werewolf/werewolf.py @@ -1,17 +1,31 @@ +import logging +from typing import List, Union + import discord from redbot.core import Config, checks, commands from redbot.core.bot import Red from redbot.core.commands import Cog +from redbot.core.utils import AsyncIter from redbot.core.utils.menus import DEFAULT_CONTROLS, menu -from .builder import ( +from werewolf.builder import ( GameBuilder, role_from_alignment, role_from_category, role_from_id, role_from_name, ) -from .game import Game +from werewolf.game import Game + +log = logging.getLogger("red.fox_v3.werewolf") + + +async def anyone_has_role( + member_list: List[discord.Member], role: discord.Role +) -> Union[None, discord.Member]: + return await AsyncIter(member_list).find( + lambda m: AsyncIter(m.roles).find(lambda r: r.id == role.id) + ) class Werewolf(Cog): @@ -43,7 +57,7 @@ class Werewolf(Cog): return def __unload(self): - print("Unload called") + log.debug("Unload called") for game in self.games.values(): del game @@ -58,9 +72,9 @@ class Werewolf(Cog): code = await gb.build_game(ctx) if code != "": - await ctx.send("Your game code is **{}**".format(code)) + await ctx.maybe_send_embed(f"Your game code is **{code}**") else: - await ctx.send("No code generated") + await ctx.maybe_send_embed("No code generated") @checks.guildowner() @commands.group() @@ -77,31 +91,36 @@ class Werewolf(Cog): """ 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 + valid, role, category, channel, log_channel = await self._get_settings(ctx) + # if not valid: + # await ctx.send("Failed to get settings") + # return None - embed = discord.Embed(title="Current Guild Settings") + embed = discord.Embed( + title="Current Guild Settings", + description=f"Valid: {valid}", + color=0x008000 if valid else 0xFF0000, + ) 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: commands.Context, role: discord.Role = None): """ - Assign the game role + Set the game role This role should not be manually assigned """ if role is None: await self.config.guild(ctx.guild).role_id.set(None) - await ctx.send("Cleared Game Role") + await ctx.maybe_send_embed("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)) + await ctx.maybe_send_embed("Game Role has been set to **{}**".format(role.name)) @commands.guild_only() @wwset.command(name="category") @@ -111,14 +130,16 @@ class Werewolf(Cog): """ if category_id is None: await self.config.guild(ctx.guild).category_id.set(None) - await ctx.send("Cleared Game Channel Category") + await ctx.maybe_send_embed("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") + await ctx.maybe_send_embed("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)) + await ctx.maybe_send_embed( + "Game Channel Category has been set to **{}**".format(category.name) + ) @commands.guild_only() @wwset.command(name="channel") @@ -128,10 +149,12 @@ class Werewolf(Cog): """ if channel is None: await self.config.guild(ctx.guild).channel_id.set(None) - await ctx.send("Cleared Game Channel") + await ctx.maybe_send_embed("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)) + await ctx.maybe_send_embed( + "Game Channel has been set to **{}**".format(channel.mention) + ) @commands.guild_only() @wwset.command(name="logchannel") @@ -141,10 +164,12 @@ class Werewolf(Cog): """ if channel is None: await self.config.guild(ctx.guild).log_channel_id.set(None) - await ctx.send("Cleared Game Log Channel") + await ctx.maybe_send_embed("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)) + await ctx.maybe_send_embed( + "Game Log Channel has been set to **{}**".format(channel.mention) + ) @commands.group() async def ww(self, ctx: commands.Context): @@ -162,9 +187,9 @@ class Werewolf(Cog): """ game = await self._get_game(ctx, game_code) if not game: - await ctx.send("Failed to start a new game") + await ctx.maybe_send_embed("Failed to start a new game") else: - await ctx.send("Game is ready to join! Use `[p]ww join`") + await ctx.maybe_send_embed("Game is ready to join! Use `[p]ww join`") @commands.guild_only() @ww.command(name="join") @@ -173,28 +198,49 @@ class Werewolf(Cog): Joins a game of Werewolf """ - game = await self._get_game(ctx) + game: Game = await self._get_game(ctx) if not game: - await ctx.send("No game to join!\nCreate a new one with `[p]ww new`") + await ctx.maybe_send_embed("Failed to join a game!") return - await game.join(ctx.author, ctx.channel) + await game.join(ctx, ctx.author) + await ctx.tick() + + @commands.guild_only() + @commands.admin() + @ww.command(name="forcejoin") + async def ww_forcejoin(self, ctx: commands.Context, target: discord.Member): + """ + Force someone to join a game of Werewolf + """ + + game: Game = await self._get_game(ctx) + + if not game: + await ctx.maybe_send_embed("Failed to join a game!") + return + + await game.join(ctx, target) + await ctx.tick() @commands.guild_only() @ww.command(name="code") async def ww_code(self, ctx: commands.Context, code): """ - Adjust game code + Adjusts the game code. + + See `[p]buildgame` to generate a new 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`") + await ctx.maybe_send_embed("No game to join!\nCreate a new one with `[p]ww new`") return await game.set_code(ctx, code) + await ctx.tick() @commands.guild_only() @ww.command(name="quit") @@ -206,6 +252,7 @@ class Werewolf(Cog): game = await self._get_game(ctx) await game.quit(ctx.author, ctx.channel) + await ctx.tick() @commands.guild_only() @ww.command(name="start") @@ -215,10 +262,12 @@ class Werewolf(Cog): """ game = await self._get_game(ctx) if not game: - await ctx.send("No game running, cannot start") + await ctx.maybe_send_embed("No game running, cannot start") if not await game.setup(ctx): - pass # Do something? + pass # ToDo something? + + await ctx.tick() @commands.guild_only() @ww.command(name="stop") @@ -226,17 +275,18 @@ class Werewolf(Cog): """ Stops the current game """ - if ctx.guild is None: - # Private message, can't get guild - await ctx.send("Cannot start game from PM!") - return + # if ctx.guild is None: + # # Private message, can't get guild + # await ctx.send("Cannot stop 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") + await ctx.maybe_send_embed("No game to stop") return game = await self._get_game(ctx) game.game_over = True - await ctx.send("Game has been stopped") + game.current_action.cancel() + await ctx.maybe_send_embed("Game has been stopped") @commands.guild_only() @ww.command(name="vote") @@ -250,7 +300,7 @@ class Werewolf(Cog): target_id = None if target_id is None: - await ctx.send("`id` must be an integer") + await ctx.maybe_send_embed("`id` must be an integer") return # if ctx.guild is None: @@ -267,7 +317,7 @@ class Werewolf(Cog): game = await self._get_game(ctx) if game is None: - await ctx.send("No game running, cannot vote") + await ctx.maybe_send_embed("No game running, cannot vote") return # Game handles response now @@ -277,7 +327,7 @@ class Werewolf(Cog): elif channel in (c["channel"] for c in game.p_channels.values()): await game.vote(ctx.author, target_id, channel) else: - await ctx.send("Nothing to vote for in this channel") + await ctx.maybe_send_embed("Nothing to vote for in this channel") @ww.command(name="choose") async def ww_choose(self, ctx: commands.Context, data): @@ -288,7 +338,7 @@ class Werewolf(Cog): """ if ctx.guild is not None: - await ctx.send("This action is only available in DM's") + await ctx.maybe_send_embed("This action is only available in DM's") return # DM nonsense, find their game # If multiple games, panic @@ -296,7 +346,7 @@ class Werewolf(Cog): if await game.get_player_by_member(ctx.author): break # game = game else: - await ctx.send("You're not part of any werewolf game") + await ctx.maybe_send_embed("You're not part of any werewolf game") return await game.choose(ctx, data) @@ -317,7 +367,7 @@ class Werewolf(Cog): if from_name: await menu(ctx, from_name, DEFAULT_CONTROLS) else: - await ctx.send("No roles containing that name were found") + await ctx.maybe_send_embed("No roles containing that name were found") @ww_search.command(name="alignment") async def ww_search_alignment(self, ctx: commands.Context, alignment: int): @@ -327,7 +377,7 @@ class Werewolf(Cog): if from_alignment: await menu(ctx, from_alignment, DEFAULT_CONTROLS) else: - await ctx.send("No roles with that alignment were found") + await ctx.maybe_send_embed("No roles with that alignment were found") @ww_search.command(name="category") async def ww_search_category(self, ctx: commands.Context, category: int): @@ -337,7 +387,7 @@ class Werewolf(Cog): if pages: await menu(ctx, pages, DEFAULT_CONTROLS) else: - await ctx.send("No roles in that category were found") + await ctx.maybe_send_embed("No roles in that category were found") @ww_search.command(name="index") async def ww_search_index(self, ctx: commands.Context, idx: int): @@ -347,24 +397,32 @@ class Werewolf(Cog): if idx_embed is not None: await ctx.send(embed=idx_embed) else: - await ctx.send("Role ID not found") + await ctx.maybe_send_embed("Role ID not found") - async def _get_game(self, ctx: commands.Context, game_code=None): - guild: discord.Guild = ctx.guild + async def _get_game(self, ctx: commands.Context, game_code=None) -> Union[Game, None]: + guild: discord.Guild = getattr(ctx, "guild", None) if guild is None: # Private message, can't get guild - await ctx.send("Cannot start game from PM!") + await ctx.maybe_send_embed("Cannot start game from DM!") return 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) + await ctx.maybe_send_embed("Starting a new game...") + valid, role, category, channel, log_channel = await self._get_settings(ctx) - if not success: - await ctx.send("Cannot start a new game") + if not valid: + await ctx.maybe_send_embed("Cannot start a new game") return None - self.games[guild.id] = Game(guild, role, category, channel, log_channel, game_code) + who_has_the_role = await anyone_has_role(guild.members, role) + if who_has_the_role: + await ctx.maybe_send_embed( + f"Cannot continue, {who_has_the_role.display_name} already has the game role." + ) + return None + self.games[guild.id] = Game( + self.bot, guild, role, category, channel, log_channel, game_code + ) return self.games[guild.id] @@ -385,23 +443,30 @@ class Werewolf(Cog): 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 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 category is None: + # # await ctx.send("Game Category is invalid") + # return False, role, 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 channel is None: + # # await ctx.send("Village Channel is invalid") + # return False, role, category, 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 + # if log_channel is None: + # # await ctx.send("Log Channel is invalid") + # return False, None, None, None, None + + return ( + role is not None and category is not None and channel is not None, + role, + category, + channel, + log_channel, + )