diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..7a6e260 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,26 @@ +--- +name: Bug report +about: Create an issue to report a bug +title: '' +labels: bug +assignees: bobloy + +--- + +**Describe the bug** + + +**To Reproduce** + +1. Load cog '...' +2. Run command '....' +3. See error + +**Expected behavior** + + +**Screenshots or Error Messages** + + +**Additional context** + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..42f5500 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,14 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[Feature Request]" +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** + + +**Describe the solution you'd like** + diff --git a/.github/ISSUE_TEMPLATE/new-audiotrivia-list.md b/.github/ISSUE_TEMPLATE/new-audiotrivia-list.md new file mode 100644 index 0000000..25bcc81 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new-audiotrivia-list.md @@ -0,0 +1,26 @@ +--- +name: New AudioTrivia List +about: Submit a new AudioTrivia list to be added +title: "[AudioTrivia Submission]" +labels: 'cog: audiotrivia' +assignees: bobloy + +--- + +**What is this trivia list?** + + +**Number of Questions** + + +**Original Content?** + + +- [ ] Yes +- [ ] No + + +**Did I test the list?** + +- [ ] Yes +- [ ] No diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..dd944c8 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,62 @@ +'cog: announcedaily': + - announcedaily/* +'cog: audiotrivia': + - audiotrivia/* +'cog: ccrole': + - ccrole/* +'cog: chatter': + - chatter/* +'cog: conquest': + - conquest/* +'cog: dad': + - dad/* +'cog: exclusiverole': + - exclusiverole/* +'cog: fifo': + - fifo/* +'cog: firstmessage': + - firstmessage/* +'cog: flag': + - flag/* +'cog: forcemention': + - forcemention/* +'cog: hangman': + - hangman +'cog: infochannel': + - infochannel/* +'cog: isitdown': + - isitdown/* +'cog: launchlib': + - launchlib/* +'cog: leaver': + - leaver/* +'cog: lovecalculator': + - lovecalculator/* +'cog: lseen': + - lseen/* +'cog: nudity': + - nudity/* +'cog: planttycoon': + - planttycoon/* +'cog: qrinvite': + - qrinvite/* +'cog: reactrestrict': + - reactrestrict/* +'cog: recyclingplant': + - recyclingplant/* +'cog: rpsls': + - rpsls/* +'cog: sayurl': + - sayurl/* +'cog: scp': + - scp/* +'cog: stealemoji': + - stealemoji/* +'cog: timerole': + - timerole/* +'cog: tts': + - tts/* +'cog: unicode': + - unicode/* +'cog: werewolf': + - werewolf \ No newline at end of file diff --git a/.github/workflows/black_check.yml b/.github/workflows/black_check.yml new file mode 100644 index 0000000..5350f98 --- /dev/null +++ b/.github/workflows/black_check.yml @@ -0,0 +1,20 @@ +# GitHub Action that uses Black to reformat the Python code in an incoming pull request. +# If all Python code in the pull request is compliant with Black then this Action does nothing. +# Othewrwise, Black is run and its changes are committed back to the incoming pull request. +# https://github.com/cclauss/autoblack + +name: black +on: [pull_request] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: '3.8' + - name: Install Black + run: pip install --upgrade --no-cache-dir black + - name: Run black --check . + run: black --check --diff -l 99 . \ No newline at end of file diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..65e6640 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,19 @@ +# This workflow will triage pull requests and apply a label based on the +# paths that are modified in the pull request. +# +# To use this workflow, you will need to set up a .github/labeler.yml +# file with configuration. For more information, see: +# https://github.com/actions/labeler + +name: Labeler +on: [pull_request] + +jobs: + label: + + runs-on: ubuntu-latest + + steps: + - uses: actions/labeler@2.2.0 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/README.md b/README.md index d8399cc..ec76ead 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,15 @@ Cog Function | conquest | **Alpha** |
Manage maps for war games and RPGsLots of additional features are planned, currently function with simple map
| | dad | **Beta** |
Tell dad jokesWorks great!
| | exclusiverole | **Alpha** |
Prevent certain roles from getting any other rolesFully functional, but pretty simple
| +| fifo | **Alpha** |
Schedule commands to be run at certain times or intervalsJust released, please report bugs as you find them. Only works for bot owner for now
| | fight | **Incomplete** |
Organize bracket tournaments within discordStill in-progress, a massive project
| +| firstmessage | **Release** |
Simple cog to provide a jump link to the first message in a channel/summary>Just released, please report bugs as you find them.
| | flag | **Alpha** |
Create temporary marks on users that expire after specified timePorted, will not import old data. Please report bugs
| | forcemention | **Alpha** |
Mentions unmentionable rolesVery simple cog, mention doesn't persist
| | hangman | **Beta** |
Play a game of hangmanSome visual glitches and needs more customization
| | howdoi | **Incomplete** |
Ask coding questions and get results from StackExchangeNot yet functional
| -| infochannel | **Beta** |
Create a channel to display server infoJust released, please report bugs
| +| infochannel | **Beta** |
Create a channel to display server infoDue to rate limits, this does not update as often as it once did
| +| isitdown | **Beta** |
Check if a website/url is downJust released, please report bugs
| | launchlib | **Beta** |
Access rocket launch dataJust released, please report bugs
| | leaver | **Beta** |
Send a message in a channel when a user leaves the serverSeems to be functional, please report any bugs or suggestions
| | lovecalculator | **Alpha** |
Calculate the love between two users[Snap-Ons] Just updated to V3
| @@ -37,7 +40,7 @@ Cog Function | unicode | **Alpha** |
Encode and Decode unicode characters[Snap-Ons] Just updated to V3
| | werewolf | **Pre-Alpha** |
Play the classic party game Werewolf within discordAnother massive project currently being developed, will be fully customizable
| -Check out my V2 cogs at [Fox-Cogs v2](https://github.com/bobloy/Fox-Cogs) +Check out *Deprecated* my V2 cogs at [Fox-Cogs v2](https://github.com/bobloy/Fox-Cogs) # Installation ### Recommended - Built-in Downloader diff --git a/audiotrivia/audiosession.py b/audiotrivia/audiosession.py index 780d4b9..1f3297b 100644 --- a/audiotrivia/audiosession.py +++ b/audiotrivia/audiosession.py @@ -1,21 +1,25 @@ """Module to manage audio trivia sessions.""" import asyncio +import logging -import lavalink from redbot.cogs.trivia import TriviaSession +from redbot.cogs.trivia.session import _parse_answers +from redbot.core.utils.chat_formatting import bold + +log = logging.getLogger("red.fox_v3.audiotrivia.audiosession") class AudioSession(TriviaSession): """Class to run a session of audio trivia""" - def __init__(self, ctx, question_list: dict, settings: dict, player: lavalink.Player): + def __init__(self, ctx, question_list: dict, settings: dict, audio=None): super().__init__(ctx, question_list, settings) - self.player = player + self.audio = audio @classmethod - def start(cls, ctx, question_list, settings, player: lavalink.Player = None): - session = cls(ctx, question_list, settings, player) + def start(cls, ctx, question_list, settings, audio=None): + session = cls(ctx, question_list, settings, audio) loop = ctx.bot.loop session._task = loop.create_task(session.run()) return session @@ -23,52 +27,95 @@ class AudioSession(TriviaSession): async def run(self): """Run the audio trivia session. - In order for the trivia session to be stopped correctly, this should - only be called internally by `TriviaSession.start`. - """ + In order for the trivia session to be stopped correctly, this should + only be called internally by `TriviaSession.start`. + """ await self._send_startup_msg() max_score = self.settings["max_score"] delay = self.settings["delay"] + audio_delay = self.settings["audio_delay"] timeout = self.settings["timeout"] - for question, answers in self._iter_questions(): + if self.audio is not None: + import lavalink + + player = lavalink.get_player(self.ctx.guild.id) + player.store("channel", self.ctx.channel.id) # What's this for? I dunno + await self.audio.set_player_settings(self.ctx) + else: + lavalink = None + player = False + + for question, answers, audio_url in self._iter_questions(): async with self.ctx.typing(): await asyncio.sleep(3) self.count += 1 - await self.player.stop() - - msg = "**Question number {}!**\n\nName this audio!".format(self.count) - await self.ctx.send(msg) - # print("Audio question: {}".format(question)) - - # await self.ctx.invoke(self.audio.play(ctx=self.ctx, query=question)) - # ctx_copy = copy(self.ctx) + msg = bold(f"Question number {self.count}!") + f"\n\n{question}" + if player: + await player.stop() + if audio_url: + if not player: + log.debug("Got an audio question in a non-audio trivia session") + continue - # await self.ctx.invoke(self.player.play, query=question) - query = question.strip("<>") - tracks = await self.player.get_tracks(query) - seconds = tracks[0].length / 1000 + load_result = await player.load_tracks(audio_url) + if ( + load_result.has_error + or load_result.load_type != lavalink.enums.LoadType.TRACK_LOADED + ): + await self.ctx.maybe_send_embed( + "Audio Track has an error, skipping. See logs for details" + ) + log.info(f"Track has error: {load_result.exception_message}") + continue + tracks = load_result.tracks + track = tracks[0] + seconds = track.length / 1000 + track.uri = "" # Hide the info from `now` + if self.settings["repeat"] and seconds < audio_delay: + # Append it until it's longer than the delay + tot_length = seconds + 0 + while tot_length < audio_delay: + player.add(self.ctx.author, track) + tot_length += seconds + else: + player.add(self.ctx.author, track) - if self.settings["repeat"] and seconds < delay: - tot_length = seconds + 0 - while tot_length < delay: - self.player.add(self.ctx.author, tracks[0]) - tot_length += seconds - else: - self.player.add(self.ctx.author, tracks[0]) - - if not self.player.current: - await self.player.play() + if not player.current: + await player.play() + await self.ctx.maybe_send_embed(msg) + log.debug(f"Audio question: {question}") - continue_ = await self.wait_for_answer(answers, delay, timeout) + continue_ = await self.wait_for_answer( + answers, audio_delay if audio_url else delay, timeout + ) if continue_ is False: break if any(score >= max_score for score in self.scores.values()): await self.end_game() break else: - await self.ctx.send("There are no more questions!") + await self.ctx.maybe_send_embed("There are no more questions!") await self.end_game() async def end_game(self): await super().end_game() - await self.player.disconnect() + if self.audio is not None: + await self.ctx.invoke(self.audio.command_disconnect) + + def _iter_questions(self): + """Iterate over questions and answers for this session. + + Yields + ------ + `tuple` + A tuple containing the question (`str`) and the answers (`tuple` of + `str`). + + """ + for question, q_data in self.question_list: + answers = _parse_answers(q_data["answers"]) + _audio = q_data["audio"] + if _audio: + yield _audio, answers, question.strip("<>") + else: + yield question, answers, _audio diff --git a/audiotrivia/audiotrivia.py b/audiotrivia/audiotrivia.py index 9465d9a..9617f32 100644 --- a/audiotrivia/audiotrivia.py +++ b/audiotrivia/audiotrivia.py @@ -1,22 +1,22 @@ import datetime +import logging import pathlib -from typing import List +from typing import List, Optional +import discord import lavalink import yaml from redbot.cogs.audio import Audio -from redbot.cogs.audio.core.utilities import validation -from redbot.cogs.trivia import LOG -from redbot.cogs.trivia.trivia import InvalidListError, Trivia -from redbot.core import commands, Config, checks +from redbot.cogs.trivia.trivia import InvalidListError, Trivia, get_core_lists +from redbot.core import Config, checks, commands from redbot.core.bot import Red from redbot.core.data_manager import cog_data_path -from redbot.core.utils.chat_formatting import box +from redbot.core.utils.chat_formatting import bold, box -# from redbot.cogs.audio.utils import userlimit +from .audiosession import AudioSession -from .audiosession import AudioSession +log = logging.getLogger("red.fox_v3.audiotrivia") class AudioTrivia(Trivia): @@ -28,12 +28,11 @@ class AudioTrivia(Trivia): def __init__(self, bot: Red): super().__init__() self.bot = bot - self.audio = None self.audioconf = Config.get_conf( self, identifier=651171001051118411410511810597, force_registration=True ) - self.audioconf.register_guild(delay=30.0, repeat=True) + self.audioconf.register_guild(audio_delay=30.0, repeat=True) @commands.group() @commands.guild_only() @@ -44,132 +43,112 @@ class AudioTrivia(Trivia): settings_dict = await audioset.all() msg = box( "**Audio settings**\n" - "Answer time limit: {delay} seconds\n" + "Answer time limit: {audio_delay} seconds\n" "Repeat Short Audio: {repeat}" "".format(**settings_dict), lang="py", ) await ctx.send(msg) - @atriviaset.command(name="delay") - async def atriviaset_delay(self, ctx: commands.Context, seconds: float): + @atriviaset.command(name="timelimit") + async def atriviaset_timelimit(self, ctx: commands.Context, seconds: float): """Set the maximum seconds permitted to answer a question.""" if seconds < 4.0: await ctx.send("Must be at least 4 seconds.") return settings = self.audioconf.guild(ctx.guild) - await settings.delay.set(seconds) - await ctx.send("Done. Maximum seconds to answer set to {}.".format(seconds)) + await settings.audo_delay.set(seconds) + await ctx.maybe_send_embed(f"Done. Maximum seconds to answer set to {seconds}.") @atriviaset.command(name="repeat") async def atriviaset_repeat(self, ctx: commands.Context, true_or_false: bool): """Set whether or not short audio will be repeated""" settings = self.audioconf.guild(ctx.guild) await settings.repeat.set(true_or_false) - await ctx.send( - "Done. Repeating short audio is now set to {}.".format(true_or_false) - ) + await ctx.maybe_send_embed(f"Done. Repeating short audio is now set to {true_or_false}.") @commands.group(invoke_without_command=True) @commands.guild_only() async def audiotrivia(self, ctx: commands.Context, *categories: str): - """Start trivia session on the specified category. + """Start trivia session on the specified category or categories. - You may list multiple categories, in which case the trivia will involve - questions from all of them. - """ + Includes Audio categories. + You may list multiple categories, in which case the trivia will involve + questions from all of them. + """ if not categories and ctx.invoked_subcommand is None: await ctx.send_help() return - - if self.audio is None: - self.audio: Audio = self.bot.get_cog("Audio") - - if self.audio is None: - await ctx.send("Audio is not loaded. Load it and try again") - return - categories = [c.lower() for c in categories] session = self._get_trivia_session(ctx.channel) if session is not None: - await ctx.send( + await ctx.maybe_send_embed( "There is already an ongoing trivia session in this channel." ) return - status = await self.audio.config.status() - notify = await self.audio.config.guild(ctx.guild).notify() - - if status: - await ctx.send( - "It is recommended to disable audio status with `{}audioset status`".format( - ctx.prefix - ) - ) - - if notify: - await ctx.send( - "It is recommended to disable audio notify with `{}audioset notify`".format( - ctx.prefix - ) - ) - - if not self.audio._player_check(ctx): - try: - if not ctx.author.voice.channel.permissions_for( - ctx.me - ).connect or self.audio.is_vc_full(ctx.author.voice.channel): - return await ctx.send( - "I don't have permission to connect to your channel." - ) - await lavalink.connect(ctx.author.voice.channel) - lavaplayer = lavalink.get_player(ctx.guild.id) - lavaplayer.store("connect", datetime.datetime.utcnow()) - except AttributeError: - return await ctx.send("Connect to a voice channel first.") - - lavaplayer = lavalink.get_player(ctx.guild.id) - lavaplayer.store("channel", ctx.channel.id) # What's this for? I dunno - - await self.audio.set_player_settings(ctx) - - if not ctx.author.voice or ctx.author.voice.channel != lavaplayer.channel: - return await ctx.send( - "You must be in the voice channel to use the audiotrivia command." - ) - trivia_dict = {} authors = [] + any_audio = False for category in reversed(categories): # We reverse the categories so that the first list's config takes # priority over the others. try: dict_ = self.get_audio_list(category) except FileNotFoundError: - await ctx.send( - "Invalid category `{0}`. See `{1}audiotrivia list`" + await ctx.maybe_send_embed( + f"Invalid category `{category}`. See `{ctx.prefix}audiotrivia list`" " for a list of trivia categories." - "".format(category, ctx.prefix) ) except InvalidListError: - await ctx.send( + await ctx.maybe_send_embed( "There was an error parsing the trivia list for" - " the `{}` category. It may be formatted" - " incorrectly.".format(category) + f" the `{category}` category. It may be formatted" + " incorrectly." ) else: - trivia_dict.update(dict_) - authors.append(trivia_dict.pop("AUTHOR", None)) + is_audio = dict_.pop("AUDIO", False) + authors.append(dict_.pop("AUTHOR", None)) + trivia_dict.update( + {_q: {"audio": is_audio, "answers": _a} for _q, _a in dict_.items()} + ) + any_audio = any_audio or is_audio continue return if not trivia_dict: - await ctx.send( + await ctx.maybe_send_embed( "The trivia list was parsed successfully, however it appears to be empty!" ) return + if not any_audio: + audio = None + else: + audio: Optional["Audio"] = self.bot.get_cog("Audio") + if audio is None: + await ctx.send("Audio lists were parsed but Audio is not loaded!") + return + status = await audio.config.status() + notify = await audio.config.guild(ctx.guild).notify() + + if status: + await ctx.maybe_send_embed( + f"It is recommended to disable audio status with `{ctx.prefix}audioset status`" + ) + + if notify: + await ctx.maybe_send_embed( + f"It is recommended to disable audio notify with `{ctx.prefix}audioset notify`" + ) + + failed = await ctx.invoke(audio.command_summon) + if failed: + return + lavaplayer = lavalink.get_player(ctx.guild.id) + lavaplayer.store("channel", ctx.channel.id) # What's this for? I dunno + settings = await self.config.guild(ctx.guild).all() audiosettings = await self.audioconf.guild(ctx.guild).all() - config = trivia_dict.pop("CONFIG", None) + config = trivia_dict.pop("CONFIG", {"answer": None})["answer"] if config and settings["allow_override"]: settings.update(config) settings["lists"] = dict(zip(categories, reversed(authors))) @@ -177,25 +156,33 @@ class AudioTrivia(Trivia): # Delay in audiosettings overwrites delay in settings combined_settings = {**settings, **audiosettings} session = AudioSession.start( - ctx=ctx, - question_list=trivia_dict, - settings=combined_settings, - player=lavaplayer, + ctx, + trivia_dict, + combined_settings, + audio, ) self.trivia_sessions.append(session) - LOG.debug("New audio trivia session; #%s in %d", ctx.channel, ctx.guild.id) + log.debug("New audio trivia session; #%s in %d", ctx.channel, ctx.guild.id) @audiotrivia.command(name="list") @commands.guild_only() async def audiotrivia_list(self, ctx: commands.Context): - """List available trivia categories.""" - lists = set(p.stem for p in self._audio_lists()) - - msg = box("**Available trivia lists**\n\n{}".format(", ".join(sorted(lists)))) - if len(msg) > 1000: - await ctx.author.send(msg) - return - await ctx.send(msg) + """List available trivia including audio categories.""" + lists = set(p.stem for p in self._all_audio_lists()) + if await ctx.embed_requested(): + await ctx.send( + embed=discord.Embed( + title="Available trivia lists", + colour=await ctx.embed_colour(), + description=", ".join(sorted(lists)), + ) + ) + else: + msg = box(bold("Available trivia lists") + "\n\n" + ", ".join(sorted(lists))) + if len(msg) > 1000: + await ctx.author.send(msg) + else: + await ctx.send(msg) def get_audio_list(self, category: str) -> dict: """Get the audiotrivia list corresponding to the given category. @@ -212,11 +199,9 @@ class AudioTrivia(Trivia): """ try: - path = next(p for p in self._audio_lists() if p.stem == category) + path = next(p for p in self._all_audio_lists() if p.stem == category) except StopIteration: - raise FileNotFoundError( - "Could not find the `{}` category.".format(category) - ) + raise FileNotFoundError("Could not find the `{}` category.".format(category)) with path.open(encoding="utf-8") as file: try: @@ -226,13 +211,15 @@ class AudioTrivia(Trivia): else: return dict_ - def _audio_lists(self) -> List[pathlib.Path]: + def _all_audio_lists(self) -> List[pathlib.Path]: + # Custom trivia lists uploaded with audiotrivia. Not necessarily audio lists personal_lists = [p.resolve() for p in cog_data_path(self).glob("*.yaml")] - return personal_lists + get_core_lists() + # Add to that custom lists uploaded with trivia and core lists + return personal_lists + get_core_audio_lists() + self._all_lists() -def get_core_lists() -> List[pathlib.Path]: +def get_core_audio_lists() -> List[pathlib.Path]: """Return a list of paths for all trivia lists packaged with the bot.""" core_lists_path = pathlib.Path(__file__).parent.resolve() / "data/lists" return list(core_lists_path.glob("*.yaml")) diff --git a/audiotrivia/data/lists/anime.yaml b/audiotrivia/data/lists/audioanime.yaml similarity index 99% rename from audiotrivia/data/lists/anime.yaml rename to audiotrivia/data/lists/audioanime.yaml index 7a27a0e..8aa518d 100644 --- a/audiotrivia/data/lists/anime.yaml +++ b/audiotrivia/data/lists/audioanime.yaml @@ -1,4 +1,5 @@ AUTHOR: Plab +AUDIO: "[Audio] Identify this Anime!" https://www.youtube.com/watch?v=2uq34TeWEdQ: - 'Hagane no Renkinjutsushi (2009)' - '(2009) الخيميائي المعدني الكامل' diff --git a/audiotrivia/data/lists/nhlgoalhorns.yaml b/audiotrivia/data/lists/audionhlgoalhorns.yaml similarity index 97% rename from audiotrivia/data/lists/nhlgoalhorns.yaml rename to audiotrivia/data/lists/audionhlgoalhorns.yaml index 689f478..9e86313 100644 --- a/audiotrivia/data/lists/nhlgoalhorns.yaml +++ b/audiotrivia/data/lists/audionhlgoalhorns.yaml @@ -1,4 +1,5 @@ AUTHOR: Lazar +AUDIO: "[Audio] Identify this NHL Team by their goal horn" https://youtu.be/6OejNXrGkK0: - Anaheim Ducks - Anaheim diff --git a/audiotrivia/data/lists/audiovideogames.yaml b/audiotrivia/data/lists/audiovideogames.yaml new file mode 100644 index 0000000..d7f594c --- /dev/null +++ b/audiotrivia/data/lists/audiovideogames.yaml @@ -0,0 +1,1763 @@ +AUTHOR: Bobloy +AUDIO: "[Audio] Identify this video game" +https://www.youtube.com/watch?v=GBPbJyxqHV0: +- Super Mario 64 +https://www.youtube.com/watch?v=0jXTBAGv9ZQ: +- Halo +https://www.youtube.com/watch?v=ZksNhHyEhE0: +- Sonic Generations +https://www.youtube.com/watch?v=4qJ-xEZhGms: +- The Legend of Zelda A Link to the Past +- A Link to the Past +https://www.youtube.com/watch?v=0J5gN1DwNDE: +- The Elder Scrolls 4 Oblivion +- Elder Scrolls 4 +- Oblivion +https://www.youtube.com/watch?v=VTsD2FjmLsw: +- Mass Effect 2 +https://www.youtube.com/watch?v=yYTY9EkFSRU: +- Mortal Kombat Deception +https://www.youtube.com/watch?v=uhscMsBhNhw: +- Super Mario Bros +https://www.youtube.com/watch?v=Jk4P10nsq4c: +- Kingdom Hearts +https://www.youtube.com/watch?v=qDnaIfiH37w: +- Super Smash Bros for Wii U +- Super Smash Bros Wii U +https://www.youtube.com/watch?v=-8wo0KBQ3oI: +- Doom +https://www.youtube.com/watch?v=jLJLyneZGKc: +- Super Street Fighter 2 +https://www.youtube.com/watch?v=T5ASJvTgpJo: +- Call Of Duty Black Ops +https://www.youtube.com/watch?v=Gb33Qnbw520: +- Super Mario Land +https://www.youtube.com/watch?v=omvdFcr0z08: +- Mortal Kombat +https://www.youtube.com/watch?v=W4VTq0sa9yg: +- GTA San Andreas +- Grand Theft Auto San Andreas +https://www.youtube.com/watch?v=7Lj1aw4J8ZA: +- Kirby's Dream Land +- Kirbys Dream Land +https://www.youtube.com/watch?v=O5FsjHRIVrc: +- Overwatch +https://www.youtube.com/watch?v=kSA4U4jNq4E: +- Hitman +https://www.youtube.com/watch?v=kzvZE4BY0hY: +- Fallout 4 +https://www.youtube.com/watch?v=5vYsVk23oxA: +- Super Metroid +https://www.youtube.com/watch?v=TVEyGntyOZQ: +- Sonic 2 +https://www.youtube.com/watch?v=EWbfixMWg4g: +- Super Smash Bros +https://www.youtube.com/watch?v=Kxp1qIYy2jQ: +- Super Smash Bros Melee +https://www.youtube.com/watch?v=zeKE0NHUtUw: +- Super Smash Bros Brawl +https://www.youtube.com/watch?v=eWSU8YOa3jU: +- Super Smash Bros 4 +https://www.youtube.com/watch?v=oC83pBZ0Z2I: +- Super Smash Bros Ultimate +https://www.youtube.com/watch?v=b8JbXCe3NJk: +- Pokémon Red/Blue/Yellow +- Pokémon Red +- Pokemon Red +- Pokémon Blue +- Pokemon Blue +- Pokémon Yellow +- Pokemon Yellow +https://www.youtube.com/watch?v=O9FeFkMyBIY: +- The Legend of Zelda Ocarina of Time +- Zelda Ocarina of Time +https://www.youtube.com/watch?v=xtJcoZ9E9ZQ: +- WOW +- World of Warcraft +https://www.youtube.com/watch?v=oDo3wlDretk: +- Street Fighter 2 +https://www.youtube.com/watch?v=l2qD1h9mGF0: +- Sonic Unleashed +https://www.youtube.com/watch?v=Oa2TCaE4MGk: +- Sonic Adventure 2 +https://www.youtube.com/watch?v=WJRoRt155mA: +- Mega Man 2 +https://www.youtube.com/watch?v=N6noiPFB3io: +- Minecraft +https://www.youtube.com/watch?v=XcHDVwL-1W8: +- Call Of Duty WWII +- Call of Duty WW2 +https://www.youtube.com/watch?v=Cgh4CFzKCps: +- Cuphead +https://www.youtube.com/watch?v=mEgE5z6Sl7Y: +- Assassin's Creed 2 +https://www.youtube.com/watch?v=KmmNYTg-wSA: +- Donkey Kong +https://www.youtube.com/watch?v=dVPF-lboK14: +- God of War 3 +https://www.youtube.com/watch?v=vf2yKCCRE50: +- New Super Mario Bros Wii +https://www.youtube.com/watch?v=BqPgeWf8vNM: +- Minecraft +https://www.youtube.com/watch?v=23hJeaLotEw: +- The Legend of Zelda Skyward Sword +- Skyward Sword +https://www.youtube.com/watch?v=hMa4hZQbrms: +- Undertale +https://www.youtube.com/watch?v=LWVjNNKDYz8: +- Ryse Son of Rome +https://www.youtube.com/watch?v=EAwWPadFsOA: +- Mortal Kombat +https://www.youtube.com/watch?v=YEZhF_98cIc: +- Uncharted 4 +https://www.youtube.com/watch?v=LRKfd5EKBtI: +- Destiny +https://www.youtube.com/watch?v=TgjOBqD_ljk: +- Super Mario Galaxy +https://www.youtube.com/watch?v=cPWBG6_jn4Y: +- The Legend of Zelda Breath of the Wild +- Breath of the Wild +- Zelda Botw +https://www.youtube.com/watch?v=M-U3sVX2G3w: +- Metroid +https://www.youtube.com/watch?v=b62RgDBmly8: +- Fire Emblem +https://www.youtube.com/watch?v=d2tdSiQAF20: +- Sonic the Hedgehog 2006 +- Sonic the Hedgehog 06 +- Sonic 06 +- Sonic 2006 +https://www.youtube.com/watch?v=qI-Takf76RY: +- Mortal Kombat +https://www.youtube.com/watch?v=GFDvcR7Ozbg: +- Kingdom hearts Birth By Sleep +https://www.youtube.com/watch?v=F-QCjTBR0Pg: +- Halo 2 Anniversary +https://www.youtube.com/watch?v=c0SuIMUoShI: +- Super Mario Bros +https://www.youtube.com/watch?v=LnSp9rgfel8: +- Borderlands +https://www.youtube.com/watch?v=cpXXfCzSmJQ: +- Red Dead Redemption +https://www.youtube.com/watch?v=CMfx3MT8ge0: +- The Elder Scrolls 5 Skyrim +- Elder Scrolls 5 +- Skyrim +https://www.youtube.com/watch?v=LtmZJwbLjb0: +- Super Mario 3D World +https://www.youtube.com/watch?v=HGXo7LExG6o: +- Tetris +https://www.youtube.com/watch?v=aUS36INQoUw: +- Fire Emblem Shadow Dragon and the Blade of Light +- Shadow Dragon and the Blade of Light +https://www.youtube.com/watch?v=s7RRgF5Ve_E: +- Undertale +https://www.youtube.com/watch?v=MuVAaU63K7k: +- Sonic Heroes +https://www.youtube.com/watch?v=7lq5rr0RTeU: +- The Legend of Zelda Twilight Princess +- Twilight Princess +https://www.youtube.com/watch?v=S62IDJBdGWA: +- Mortal Kombat X +- Mortal Komba 10 +https://www.youtube.com/watch?v=_50vhftK66I: +- Super Mario World +https://www.youtube.com/watch?v=cZjSrW4p7r8: +- Sonic Adventure 2 +https://www.youtube.com/watch?v=e-nXonpnFHI: +- Monster Hunter Frontier G6 +https://www.youtube.com/watch?v=_dWMv2hfck0: +- Halo 2 +https://www.youtube.com/watch?v=SV0IF4XVBlE: +- Battlefield 1 +https://www.youtube.com/watch?v=rqIRZHPuiqE: +- Middle Earth Shadow of War +- Shadow of War +https://www.youtube.com/watch?v=qQGh4dy1cCA: +- Killzone 3 +https://www.youtube.com/watch?v=e9r5hx47kxM: +- Super Mario Odyssey +https://www.youtube.com/watch?v=SXKrsJZWqK0: +- Batman Arkham City +https://www.youtube.com/watch?v=Wbk8WeQU0rc: +- Far Cry +https://www.youtube.com/watch?v=4i8qAZOu5-g: +- Crash Bandicoot +https://www.youtube.com/watch?v=qEpaZR2Dvlg: +- Super Mario World +https://www.youtube.com/watch?v=-uEc8_dcYUc: +- Battlefield 4 +https://www.youtube.com/watch?v=jqE8M2ZnFL8: +- Grand Theft Auto 4 +https://www.youtube.com/watch?v=_kTBbTSjZpI: +- Shadow the Hedgehog +https://www.youtube.com/watch?v=QG77HTdreh0: +- Kid Icarus Uprising +https://www.youtube.com/watch?v=Z6NaZrPQGfY: +- Sonic Adventure 2 +https://www.youtube.com/watch?v=GkQUDi0pMAE: +- Marvel Ultimate Alliance +https://www.youtube.com/watch?v=o78T9-I4OGA: +- The Legend of Zelda The Wind Waker +- Zelda Wind Waker +- Zelda The Wind Waker +https://www.youtube.com/watch?v=dLDWYSQkUbE: +- Halo Combat Evolved +https://www.youtube.com/watch?v=mp9mzmq5Oas: +- Tokyo Mirage Sessions FE +https://www.youtube.com/watch?v=T5_vKsROSU0: +- Halo 2 +https://www.youtube.com/watch?v=MC0hV3dea9g: +- Sonic R +https://www.youtube.com/watch?v=dHkyqHlQ_is: +- Marvel Ultimate Alliance +https://www.youtube.com/watch?v=VfGeeZwv4Os: +- Diablo III +- Diablo 3 +https://www.youtube.com/watch?v=E3tkgU0pQmQ: +- Super Mario Sunshine +https://www.youtube.com/watch?v=53aDI5K49F4: +- Resident Evil 2 +https://www.youtube.com/watch?v=f43swtBDkuo: +- Marvel Ultimate Alliance +https://www.youtube.com/watch?v=cn144yWiZF4: +- Silent Hill 2 +https://www.youtube.com/watch?v=JSR5rROsvL0: +- Super Mario 3D Land +https://www.youtube.com/watch?v=CXxEUEK2_cs: +- Forza Motorsport 6 +https://www.youtube.com/watch?v=r8sB8N44eBE: +- Sonic Adventure (Theme of Knuckles) +- Sonic Adventure +https://www.youtube.com/watch?v=jrB6DLpIDaA: +- Rocket League +https://www.youtube.com/watch?v=6vY7S6iwwlQ: +- GTA San Andreas +- Grand Theft Auto San Andreas +https://www.youtube.com/watch?v=ndaJphlTPY8: +- Gears of War +https://www.youtube.com/watch?v=j9JWZ0mlX94: +- Luigi's Mansion +- Luigis Mansion +https://www.youtube.com/watch?v=zh4r_sXHyjc: +- Marvel Ultimate Alliance +https://www.youtube.com/watch?v=CvERHiTfx9w: +- Sonic Adventure 2 +https://www.youtube.com/watch?v=1TxEdJaZkY4: +- Dead by Daylight +https://www.youtube.com/watch?v=rxkiOLs06Z8: +- Injustice 2 +https://www.youtube.com/watch?v=bG0xho_yoaU: +- Marvel Ultimate Alliance +https://www.youtube.com/watch?v=xsyPHkeyz6o: +- Super Mario Galaxy 2 +https://www.youtube.com/watch?v=MdQvcgBmexw: +- Battlefront 2 +https://www.youtube.com/watch?v=6lR4nexN7RE: +- DOTA 2 +https://www.youtube.com/watch?v=m0NhFUw8mpI: +- Super Smash Bros Wii U +https://www.youtube.com/watch?v=E9s1ltPGQOo: +- Mii Channel +https://www.youtube.com/watch?v=QSBco8kVuZM: +- Fallout 3 +https://www.youtube.com/watch?v=Tz5Ld2STm2M: +- Ghost Recon +https://www.youtube.com/watch?v=oY9m2sHQwLs: +- Sonic R +https://www.youtube.com/watch?v=NF8kT_6giu8: +- Halo 2 +https://www.youtube.com/watch?v=EK3q3Jb3TCQ: +- The Elder Scrolls 5 Skyrim +- Elder Scrolls 5 +- Skyrim +https://www.youtube.com/watch?v=h4fOIVlEOIY: +- Assassin’s Creed Origins +- Assasins Creed Origins +https://www.youtube.com/watch?v=mziw3FQkZYg: +- Metroid Prime +https://www.youtube.com/watch?v=CXdNLpOrwFQ: +- Pikmin 3 +https://www.youtube.com/watch?v=RZFhVs6OGAc: +- Wii Fit +https://www.youtube.com/watch?v=UW-7Em3BYiA: +- Star Fox 64 +https://www.youtube.com/watch?v=9k-XrIGobsA: +- Super Mario 3D World +https://www.youtube.com/watch?v=kohY_vaMp0c: +- Bayonetta +https://www.youtube.com/watch?v=4roOuDyLkNo: +- Fallout 3 +https://www.youtube.com/watch?v=4wzRjZh3EkM: +- DOTA 2 +https://www.youtube.com/watch?v=OiwLQAA_V8E: +- Fire Emblem Fates +https://www.youtube.com/watch?v=UigzN-4JR14: +- Kingdom Hearts +https://www.youtube.com/watch?v=bIoPaeigMJw: +- Halo 2 +https://www.youtube.com/watch?v=JIE94pn4iJs: +- Killer Instinct +https://www.youtube.com/watch?v=Y9Jt52Q6GD4: +- Deadpool +https://www.youtube.com/watch?v=uNBzfe3TAEs: +- Shadow the Hedgehog +https://www.youtube.com/watch?v=PZVNi6L7g54: +- Mario Party 8 +https://www.youtube.com/watch?v=0uom8gJxe_8: +- Overwatch +https://www.youtube.com/watch?v=OCH6nQYflwY: +- Kirby's Dream Land +- Kirbys Dream Land +https://www.youtube.com/watch?v=PDURJZp3Sv4: +- World of Warcraft +- WoW +https://www.youtube.com/watch?v=3DYqiG1VmMQ: +- Diablo 2 +https://www.youtube.com/watch?v=xWSiztHV0us: +- Mortal Kombat Armageddon +https://www.youtube.com/watch?v=D6fkkVMsrAA: +- Super Smash Bros 3DS +- Mr Game and Watch +https://www.youtube.com/watch?v=tz82xbLvK_k: +- Undertale +https://www.youtube.com/watch?v=EV6E13xODyA: +- Bayonetta +https://www.youtube.com/watch?v=bq_jS6o3OoY: +- Super Mario 64 +https://www.youtube.com/watch?v=M3CSWLFL5u8: +- The Legend of Zelda The Wind Waker +- Zelda Wind Waker +- Zelda The Wind Waker +https://www.youtube.com/watch?v=PnzjqTvHJto: +- Destiny +https://www.youtube.com/watch?v=JntQ1X46qgg: +- Sonic Adventure 2 +https://www.youtube.com/watch?v=M9Ki3br3Qec: +- Deadrising +https://www.youtube.com/watch?v=bwaFADxmoeo: +- Splatoon +https://www.youtube.com/watch?v=Tc0fDGVQtJk: +- Metal Gear Solid +https://www.youtube.com/watch?v=ZvwQNlNIcr8: +- Forza Horizon 3 +https://www.youtube.com/watch?v=B7eRmpqtL40: +- Call of Duty Ghosts +https://www.youtube.com/watch?v=qURei6svd90: +- Doom II +- Doom 2 +https://www.youtube.com/watch?v=U1sYDWzhEog: +- Mario Party 8 +https://www.youtube.com/watch?v=aKKFjNVZkHU: +- Marvel vs Capcom 3 +https://www.youtube.com/watch?v=PDM2qukzKwg: +- Team Fortress 2 +https://www.youtube.com/watch?v=0nlJuwO0GDs: +- League of Legends +https://www.youtube.com/watch?v=UuL8nnyzf14: +- Fire Emblem The Sacred Stones +https://www.youtube.com/watch?v=ARFeJ3z6wes: +- Mario Party DS +https://www.youtube.com/watch?v=pNB4Cy4mftI: +- Sonic and the Black Knight +https://www.youtube.com/watch?v=GWba_XNUxtA: +- Quantum Break +https://www.youtube.com/watch?v=7XjrTV6M84c: +- Dead Rising 3 +https://www.youtube.com/watch?v=LjYWmShOoA4: +- Overwatch +https://www.youtube.com/watch?v=1pga6OLyrAg: +- Borderlands +https://www.youtube.com/watch?v=1OEyASR5C5U: +- Uncharted +https://www.youtube.com/watch?v=K-pJ1xMF7QE: +- League of Legends +https://www.youtube.com/watch?v=9q_MEnn5w0I: +- Minecraft +https://www.youtube.com/watch?v=5WduvI0CZ4c: +- Mega Man +https://www.youtube.com/watch?v=mB7veHQPF6M: +- Halo 2 +https://www.youtube.com/watch?v=mtxlFKP0OgY: +- Call of Duty Modern Warfare 2 +- Modern Warfare 2 +https://www.youtube.com/watch?v=2Jmty_NiaXc: +- Pokémon Red/Blue/Yellow +- Pokémon Red +- Pokemon Red +- Pokémon Blue +- Pokemon Blue +- Pokémon Yellow +- Pokemon Yellow +https://www.youtube.com/watch?v=6YQm2M-v4l0: +- Metal Gear +https://www.youtube.com/watch?v=r9spthA_nAA: +- Earthbound +https://www.youtube.com/watch?v=M_hUepNd52Q: +- Fallout New Vegas +https://www.youtube.com/watch?v=_X2A7qLDuLQ: +- GTA San Andreas +- Grand Theft Auto San Andreas +https://www.youtube.com/watch?v=8SM_39H-ztc: +- Kingdom Hearts +https://www.youtube.com/watch?v=Vj3dgToY_Fg: +- Ratchet and clank +https://www.youtube.com/watch?v=jgn3GSiYtEc: +- Middle earth Shadow of Mordor +- Shadow of Mordor +https://www.youtube.com/watch?v=sZ1f6BEkn_g: +- Paper Mario The Thousand-Year Door +- Paper Mario The Thousand Year Door +https://www.youtube.com/watch?v=T7AkuzZOSWM: +- DOTA 2 +https://www.youtube.com/watch?v=ixR9kOAR3ZY: +- Dragon Quest IX Sentinels of the Starry Skies +- Dragon Quest 9 +https://www.youtube.com/watch?v=WiQ1O5of0u4: +- Street Fighter II +- Street Fighter 2 +https://www.youtube.com/watch?v=0hEYvdMoF2g: +- The Legend of Zelda Ocarina of Time +- Zelda Ocarina of Time +https://www.youtube.com/watch?v=mK4mP7grUWY: +- Final Fantasy IX +- Final Fantasy 9 +https://www.youtube.com/watch?v=YzfEjZV46wM: +- Forza Motorsport +https://www.youtube.com/watch?v=vX7G4Qq6Li0: +- Shadow the Hedgehog +https://www.youtube.com/watch?v=95jDylAOsBQ: +- Assassins Creed +https://www.youtube.com/watch?v=EjazC45Qkww: +- Castlevania II Simon's Quest +- Castlevania 2 +https://www.youtube.com/watch?v=w09rc1VfWRQ: +- Forza Motorsport 4 +https://www.youtube.com/watch?v=Nc9w4QoSdfk: +- Tomb Raider +https://www.youtube.com/watch?v=VD9XmYnml_M: +- GTA V +- GTA 5 +- Grand Theft Auto 5 +https://www.youtube.com/watch?v=D5Z95cH7iDA: +- Ghost Recon Island Thunder +https://www.youtube.com/watch?v=o9Pu92St5O0: +- Metal Gear Solid 4 +https://www.youtube.com/watch?v=Bm2U7fqtSak: +- Ninja Gaiden +https://www.youtube.com/watch?v=NfCwWu8z8zE: +- Resident Evil +https://www.youtube.com/watch?v=zWHMj6ccSwA: +- Nintendo Land +https://www.youtube.com/watch?v=L8Vbg-1kPvg: +- Far Cry 5 +https://www.youtube.com/watch?v=hOju2ThS--M: +- Sonic the Hedgehog 2006 +- Sonic the Hedgehog 06 +- Sonic 06 +- Sonic 2006 +https://www.youtube.com/watch?v=EYW7-hNXZlM: +- Sonic CD +https://www.youtube.com/watch?v=ijEOqab3StE: +- Shadow the Hedgehog +https://www.youtube.com/watch?v=nJD-Ufi1jGk: +- The Elder Scrolls 3 Morrowind +- Elder Scrolls 3 +- Morrowind +https://www.youtube.com/watch?v=Iy4iQvJo24U: +- Crysis 2 +https://www.youtube.com/watch?v=o_ayLF9vdls: +- Dragon Age Origins +https://www.youtube.com/watch?v=LFcH84oNU6s: +- Skies of Arcadia +https://www.youtube.com/watch?v=9wMjq58Fjvo: +- Castle Crashers +https://www.youtube.com/watch?v=WA2WjP6sgrc: +- Donkey Kong Country Tropical Freeze +- Donkey Kong Tropical Freeze +https://www.youtube.com/watch?v=_Uzlm2MaCWw: +- MegaMan Maverick Hunter X +https://www.youtube.com/watch?v=Vm7aNxzWTJk: +- Prince of Persia +https://www.youtube.com/watch?v=viM0-3PXef0: +- The Witcher 3 +- Witcher 3 +https://www.youtube.com/watch?v=TO7UI0WIqVw: +- Super Smash Bros Brawl +https://www.youtube.com/watch?v=Y6ljFaKRTrI: +- Portal +https://www.youtube.com/watch?v=8yj-25MOgOM: +- Star Fox Zero +https://www.youtube.com/watch?v=W7rhEKTX-sE: +- Shovel Knight +https://www.youtube.com/watch?v=dTZ8uhJ5hIE: +- Kirby's Epic Yarn Music +- Kirbys Epic Yarn Music +https://www.youtube.com/watch?v=GQZLEegUK74: +- Goldeneye 007 +https://www.youtube.com/watch?v=zz8m1oEkW5k: +- Tetris Blitz +https://www.youtube.com/watch?v=ya3yxTbkh5s: +- Okami +https://www.youtube.com/watch?v=OYc_Vosp9_Y: +- The Legend of Zelda A Link Between Worlds +- Zelda A Link Between Worlds +https://www.youtube.com/watch?v=6LB7LZZGpkw: +- Silent Hill 2 +https://www.youtube.com/watch?v=TLYimAlnxEk: +- Mario Kart 8 +https://www.youtube.com/watch?v=-_Lwf6uaYOs: +- Kid Icarus Uprising +https://www.youtube.com/watch?v=H1etAFiAPYQ: +- Pikmin 3 +https://www.youtube.com/watch?v=jrbN2y5r-vU: +- Starcraft +https://www.youtube.com/watch?v=3o_RwQysgA8: +- The Last of Us +https://www.youtube.com/watch?v=75OlLWtV8gc: +- Pokémon Diamond/Pearl/Platinum +- Pokémon Diamond +- Pokemon Diamond +- Pokémon Pearl +- Pokemon Pearl +- Pokémon Platinum +- Pokemon Platinum +https://www.youtube.com/watch?v=sWTqNGUFF0g: +- Gunman Clive +https://www.youtube.com/watch?v=D4ragdexomw: +- Apollo Justice Ace Attorney +- Ace Attorney Apollo Justice +https://www.youtube.com/watch?v=poxDJHoYxCc: +- Gravity Rush +- Gravity Daze +https://www.youtube.com/watch?v=kwmOSMttZtM: +- Luigi's Mansion Dark Moon +- Luigis Mansion Dark Moon +https://www.youtube.com/watch?v=Ib19evMJJVI: +- PAYDAY The Heist +https://www.youtube.com/watch?v=7jVPwHqXz8A: +- Metroid Fusion +https://www.youtube.com/watch?v=utOa8ZMKwSA: +- Super Smash Bros Brawl +https://www.youtube.com/watch?v=2OOYZcVIp8w: +- Super Mario Galaxy +https://www.youtube.com/watch?v=u4jlk2x3Om0: +- Banjo-Kazooie Nuts & Bolts +- Banjo-Kazooie Nuts and Bolts +- Banjo Kazooie Nuts and Bolts +https://www.youtube.com/watch?v=mbsm2YXfAzo: +- Mortal Kombat Deception +https://www.youtube.com/watch?v=WB1I3PV7Ml8: +- Crash Bandicoot +https://www.youtube.com/watch?v=gy0T8nmw5GQ: +- Hitman 2 Silent Assassin +- Hitman 2 +https://www.youtube.com/watch?v=WuUVKOwUEA0: +- Super Mario Bros 3 +https://www.youtube.com/watch?v=6hgK3XJWSOk: +- Startropics +https://www.youtube.com/watch?v=_5iRzoSLWMM: +- The Legend of Zelda A Link to the Past +- A Link to the Past +https://www.youtube.com/watch?v=bV80prS5hBk: +- Silent Hill +https://www.youtube.com/watch?v=RDeJMAsrZMU: +- Sonic Adventure 2 +https://www.youtube.com/watch?v=QEmbOL3AAEs: +- Final Fantasy VI +- Final Fantasy 6 +https://www.youtube.com/watch?v=CoOTDI2KZco: +- Mega Man 3 +https://www.youtube.com/watch?v=Xeloqt4Wkcw: +- Streets Of Rage 2 +https://www.youtube.com/watch?v=wOQ5YyAorrw: +- Pokémon X & Y +- Pokemon X +- Pokemon Y +https://www.youtube.com/watch?v=CU1goWmsTRg: +- Duck Hunt +https://www.youtube.com/watch?v=oJqlpwN9mUM: +- Mario Party 2 +https://www.youtube.com/watch?v=ye78aoUtqOY: +- Pokémon GO +- Pokemon GO +https://www.youtube.com/watch?v=hf_D3pK6LVI: +- Phoenix Write Ace Attorney +https://www.youtube.com/watch?v=7qvudKHKozc: +- Shining Force II +- Shining Force 2 +https://www.youtube.com/watch?v=xPJcOybiCKM: +- Super Mario 64 +https://www.youtube.com/watch?v=IHxFTQYAqfc: +- Halo 3 +https://www.youtube.com/watch?v=0t9TlXAnDDM: +- Call of Juarez Gunslinger +https://www.youtube.com/watch?v=LuZy6A9luhU: +- Far Cry 3 +https://www.youtube.com/watch?v=HcvX3Md4uvM: +- Rainbow Six Vegas 2 +https://www.youtube.com/watch?v=RQYh342H5ec: +- Nintendo 3DS Nintendo Video +- Nintendo Video +https://www.youtube.com/watch?v=EMlQiS372Y0: +- Wii sports resort +https://www.youtube.com/watch?v=OtXHRSRQZvU: +- DK Rap +- Super Smash Bros Melee +- Donkey Kong 64 +https://www.youtube.com/watch?v=fmHEwxYdwcA: +- Mario & Luigi Bowsers Inside Story +- Bowsers Inside Story +- Mario and Luigi Bowsers Inside Story +https://www.youtube.com/watch?v=9cXiGjjLZAI: +- Pikmin 3 +https://www.youtube.com/watch?v=Q47iqNfsA80: +- The Legend of Zelda A Link Between Worlds +- A Link Between Worlds +https://www.youtube.com/watch?v=uQjcAvGTD7M: +- Professor Layton vs. Phoenix Wright Ace Attorney +- Professor Layton vs Phoenix Wright +https://www.youtube.com/watch?v=tBTb8H6vJe8: +- Donkey Kong 64 +https://www.youtube.com/watch?v=qQFseWcceDs: +- Pilotwings +https://www.youtube.com/watch?v=OVZ7EaqplsI: +- eShop Wii U +- eShop +https://www.youtube.com/watch?v=2hVsp2ncHoA: +- Marvel Ultimate Alliance +https://www.youtube.com/watch?v=kU36OmpCK_c: +- The Legend of Zelda Skyward Sword +- Skyward Sword +https://www.youtube.com/watch?v=7TCI6rdokD4: +- Street Fighter II +- Street Fighter 2 +https://www.youtube.com/watch?v=-unUysuRmWo: +- Sonic Adventure 2 +https://www.youtube.com/watch?v=QoIE04AqDgI: +- Kirby's Epic Yarn +- Kirbys Epic Yarn +https://www.youtube.com/watch?v=lSaDibas_Sw: +- Call of Duty Black Ops 3 +- Black Ops 3 +https://www.youtube.com/watch?v=HTUq3Ik1GHM: +- Kingdom Hearts II +- Kingdom Hearts 2 +https://www.youtube.com/watch?v=6ylyRyoUyH8: +- One Piece Pirate Warriors +https://www.youtube.com/watch?v=FJd6TMOZuJQ: +- Pac-Man +- Pac Man +https://www.youtube.com/watch?v=mBwmfDh7qtA: +- Dig Dug +https://www.youtube.com/watch?v=XjPF3AwVPM4: +- Finaly Fantasy +https://www.youtube.com/watch?v=KnoXA_EAnjo: +- Sonic Heroes +https://www.youtube.com/watch?v=XHpXEfa1kzk: +- Super Smash Bros Brawl +https://www.youtube.com/watch?v=mld2IxYXgt0: +- Super Robot Wars K +- SRW K +https://www.youtube.com/watch?v=Xi31NAktYDw: +- BioShock 2 +https://www.youtube.com/watch?v=YqyhezUoDj4: +- Call of Duty World at War +- World at War +https://www.youtube.com/watch?v=hOB4q01VCVg: +- Battlefield 1943 +https://www.youtube.com/watch?v=_cZoUYndFHo: +- Cuphead +https://www.youtube.com/watch?v=ojS8lCeUgsg: +- Deadpool +https://www.youtube.com/watch?v=2HTs2n0q-RA: +- Sonic Riders Zero Gravity +https://www.youtube.com/watch?v=yykgQClYlCk: +- TitanFall +https://www.youtube.com/watch?v=I6XMQYkPczI: +- Bravely Default +https://www.youtube.com/watch?v=q_TxCygjNIM: +- Deadrising +https://www.youtube.com/watch?v=2iLl_uusujo: +- STAR WARS The Old Republic +https://www.youtube.com/watch?v=KDyTjQmIWAw: +- Check Mii Out Channel Contest Menu Wii +- Check Mii Out +https://www.youtube.com/watch?v=VhjwXsDJQ2Y: +- Wii Sports +https://www.youtube.com/watch?v=fxMW5__vaDQ: +- Super Mario 64 +https://www.youtube.com/watch?v=4DJzm9cFGkg: +- Puyopuyo Tetris +- Puyo puyo Tetris +https://www.youtube.com/watch?v=G1MfL1oGN0E: +- Call of Duty Modern Warfare 3 +- Modern Warfare 3 +https://www.youtube.com/watch?v=bRLzfoySFWc: +- Assassin's Creed 4 Black Flag +- Assasins Creed 4 +- Assasins Creed Black Flag +https://www.youtube.com/watch?v=30lnA-haJiM: +- XBOX 360 Avatar Editor +- Avatar Editor +https://www.youtube.com/watch?v=czTksCF6X8Y: +- Spider-Man 2 The Game +- Spider Man 2 +- Spider-Man 2 +https://www.youtube.com/watch?v=_FAYOrpr-Fk: +- Wii Sports +https://www.youtube.com/watch?v=UbqoImQZ-EY: +- Touhou Embodiment of Scarlet Devil +- Touhou EoSD +- Embodiment of Scarlet Devil +https://www.youtube.com/watch?v=qIHqpjVxTtY: +- Super Stardust Delta +https://www.youtube.com/watch?v=VU7qTVkjMWU: +- World of Warcraft Wrath of the Lich King +- Wrath of the Lich King +- WoW WotLK +https://www.youtube.com/watch?v=YB2hQicc13Q: +- Super Smash Bros Melee +https://www.youtube.com/watch?v=9NBu_N9paNM: +- Metro Last Light +https://www.youtube.com/watch?v=aJOovInlk-w: +- Assassin's Creed 4 Black Flag +- Assasins Creed 4 +- Assasins Creed Black Flag +https://www.youtube.com/watch?v=6OYeyGi67UI: +- Call of Duty Ghosts +https://www.youtube.com/watch?v=IuLMkfG_Xu0: +- World of Warcraft +- WoW +https://www.youtube.com/watch?v=gR03h48G9rw: +- Destiny +https://www.youtube.com/watch?v=e87BLzzcwqQ: +- Halo Combat Evolved +https://www.youtube.com/watch?v=0_FquzflYYU: +- Luigis Mansion +- Luigi's Mansion +https://www.youtube.com/watch?v=I3XPus2T58o: +- The Legend of Zelda Twilight Princess +- Twilight Princess +https://www.youtube.com/watch?v=pVFYljt05hg: +- Metal Gear Solid 3 +https://www.youtube.com/watch?v=XHXVFBKZ9i8: +- Rhythm Heaven +https://www.youtube.com/watch?v=9hUT8VsLIG4: +- Pac-Man Championship Edition 2 +- Pac Man Championship Edition 2 +- Pac-Man CE 2 +- Pac Man CE 2 +https://www.youtube.com/watch?v=OziIZxI-Am4: +- Super Mario Maker +https://www.youtube.com/watch?v=frWaTmdN1Yk: +- Saga Frontier 2 +https://www.youtube.com/watch?v=r3m8_TDltwg: +- The Simpsons Hit & Run +- The Simpsons Hit and Run +https://www.youtube.com/watch?v=6kZ5TiSl2d8: +- Metal Gear Solid 2 +https://www.youtube.com/watch?v=CvL6fzXOJyY: +- Middle Earth Shadow of War +- Shadow of War +https://www.youtube.com/watch?v=zsxwbxS6LRM: +- Pocky & Rocky +- Pocky and Rocky +https://www.youtube.com/watch?v=YEo8vfvU6t8: +- Mario Golf World Tour +https://www.youtube.com/watch?v=q38jsbJ0afg: +- Marvel Ultimate Alliance 2 +https://www.youtube.com/watch?v=jbppvtGktvA: +- Super Street Fighter IV +- Super Street Fighter 4 +https://www.youtube.com/watch?v=kEdRtzNAh0g: +- Mortal Kombat +https://www.youtube.com/watch?v=SiLykPb_14g: +- Snipperclips +https://www.youtube.com/watch?v=2LhOV_ygYlY: +- Ghostbusters The Videogame +- Ghostbusters +https://www.youtube.com/watch?v=TUA9WwTV10M: +- Back to the Future the Game +- Back to the Future +https://www.youtube.com/watch?v=krISRDj6_2w: +- DSi Shop Theme +- DSi Shop +https://www.youtube.com/watch?v=R5DJENl__ZE: +- The Elder Scrolls 3 Morrowind +- Elder Scrolls 3 +- Morrowind +https://www.youtube.com/watch?v=2MDXwJ7Skik: +- Mortal Kombat 9 +https://www.youtube.com/watch?v=NKkXYxFh9M0: +- Killzone 3 +https://www.youtube.com/watch?v=5rfpGcIWe7E: +- BioShock 2 +https://www.youtube.com/watch?v=euJNO3xzprQ: +- Call of Duty Black Ops 3 +- Black Ops 3 +https://www.youtube.com/watch?v=L8EfpcF9rRc: +- Super Smash Bros Brawl +https://www.youtube.com/watch?v=LaM9YRUsDXs: +- Dark Souls +https://www.youtube.com/watch?v=nghTrcPBp3s: +- Yoshi's Story +- Yoshis Story +https://www.youtube.com/watch?v=l7vpszYDFEo: +- Destiny 2 +https://www.youtube.com/watch?v=ukMWZymYV-E: +- Borderlands +https://www.youtube.com/watch?v=ybhjox7LhmY: +- Mario Kart Wii +https://www.youtube.com/watch?v=9vevdlA1mFE: +- Pocky & Rocky 2 +- Pocky and Rocky 2 +https://www.youtube.com/watch?v=sZZgenIEL9o: +- Call of Duty Black Ops 4 +- Black Ops 4 +https://www.youtube.com/watch?v=OTF5gn5S0zs: +- Super Mario Bros Super Smash Bros Brawl +- Super Mario Bros +- Super Smash Bros Brawl +https://www.youtube.com/watch?v=OiWIpzor_Vk: +- Far Cry 5 +https://www.youtube.com/watch?v=h-xih93YIwk: +- Super Mario Bros 3 +https://www.youtube.com/watch?v=aHW0Vn-wRzs: +- Team Sonic Racing +https://www.youtube.com/watch?v=DehK_Y0TUbE: +- Angry Birds +https://www.youtube.com/watch?v=eVKj3u8JUm0: +- Super Mario 64 +https://www.youtube.com/watch?v=vCF_mG9T6-Y: +- Super Smash Bros Ultimate +- Super Smash Bros Melee +https://www.youtube.com/watch?v=o1rXwfuffwk: +- Battle Stadium D.O.N. +- Battle Stadium DON +https://www.youtube.com/watch?v=y_qHuDjE3CQ: +- Undertale +https://www.youtube.com/watch?v=kNK3gZnd3Qc: +- LEGO Marvel Super Heroes +https://www.youtube.com/watch?v=a6t_uyg_pF8: +- Final Fantasy VI +- Final Fantasy 6 +https://www.youtube.com/watch?v=Vc1wzDWFvf8: +- Super Smash Bros Melee +https://www.youtube.com/watch?v=4QR21UOXUTk: +- J Stars Victory Vs +https://www.youtube.com/watch?v=8avMLHvLwRQ: +- Wii Shop Channel +- Wii Shop +https://www.youtube.com/watch?v=d5c4KOopwLs: +- Wii Sports +https://www.youtube.com/watch?v=K5PNe0a04_I: +- Fire Emblem Echoes Shadows of Valentia +- Fire Emblem Echoes +- Shadows of Valentia +https://www.youtube.com/watch?v=8GxAi9b0ZvM: +- Super Mario Odyssey +https://www.youtube.com/watch?v=uTcOC04eEro: +- Yu-Gi-Oh! Duel Links +- Yu-Gi-Oh Duel Links +- YuGiOh Duel Links +- Yu Gi Oh Duel Links +https://www.youtube.com/watch?v=nTSXf2YVNUs: +- Final Fantasy III +- Final Fantasy 3 +https://www.youtube.com/watch?v=96uAHbo2f0o: +- Super Smash Bros Brawl +https://www.youtube.com/watch?v=AD0SP68Z2D0: +- Tom Clancys The Division +- The Division +https://www.youtube.com/watch?v=XmK8vhe6_yI: +- Destiny +https://www.youtube.com/watch?v=OM_qluyUGtY: +- Halo 2 +https://www.youtube.com/watch?v=KCQpsPFOEyU: +- Tom Clancys Rainbow Six Siege +- Rainbow Six Siege +- R6 Siege +https://www.youtube.com/watch?v=Rvi6c8toWJM: +- Counter-Strike Global Offensive +- CS GO +- CSGO +- Counter Strike GO +- Counter Strike Global Offensive +https://www.youtube.com/watch?v=WGkBan-hrR0: +- Ninja Gaiden 2 +https://www.youtube.com/watch?v=NPKG8HOyH50: +- Call of Duty Black Ops 4 +- Black Ops 4 +https://www.youtube.com/watch?v=Pva3UHPUHns: +- Luigis Mansion +https://www.youtube.com/watch?v=Gv__pDOd9R4: +- Donkey Kong 64 +https://www.youtube.com/watch?v=IJxIR35cWFg: +- Mario Party 8 +https://www.youtube.com/watch?v=6bWqgqcaQVs: +- Batman Arkham City +https://www.youtube.com/watch?v=FL6BRBzv8Ec: +- Anthem +https://www.youtube.com/watch?v=FG9uTzsqLNc: +- Counter-Strike Global Offensive +- CS GO +- CSGO +- Counter Strike GO +- Counter Strike Global Offensive +https://www.youtube.com/watch?v=-s9hGP8EPC4: +- Crackdown +https://www.youtube.com/watch?v=p7ew-8C3G_M: +- Killzone +https://www.youtube.com/watch?v=HyoZspjzaT8: +- Dragonball Z Budokai Tenkaichi 2 +https://www.youtube.com/watch?v=KveGWSYDxKY: +- Minecraft +https://www.youtube.com/watch?v=rXGdYmigdzw: +- Conker's Bad Fur Day +- Conkers Bad Fur Day +https://www.youtube.com/watch?v=bxJaHn4OcOk: +- Nintendo 3DS Camera Music +- 3DS Camera +https://www.youtube.com/watch?v=YZ3XjVVNagU: +- Undertale +https://www.youtube.com/watch?v=Rdeuye6BfmA: +- Marvel Ultimate Alliance +https://www.youtube.com/watch?v=rVzkd6B7LCA: +- Halo 3 +https://www.youtube.com/watch?v=IhQ45MHHZGA: +- Anthem +https://www.youtube.com/watch?v=rYpqRmsA94Y: +- Spider-Man PS4 +- Spider Man +https://www.youtube.com/watch?v=dFkuCBF-bf0: +- Sonic Colors +https://www.youtube.com/watch?v=jPfcnzPixBI: +- One Piece World Seeker +https://www.youtube.com/watch?v=KamjTqi4VjI: +- Kirby Air Ride +https://www.youtube.com/watch?v=7kCXNPZscFU: +- Dance Dance Revolution Mario Mix +- DDR Mario Mix +https://www.youtube.com/watch?v=CaXapWQeeeg: +- Spongebob Battle for Bikini Bottom +https://www.youtube.com/watch?v=cSiyDP_B5vY: +- The Witcher 3 Wild Hunt +- Witcher 3 +- Witcher Wild Hunt +https://www.youtube.com/watch?v=bGTWOWeFFw8: +- Marvel vs Capcom 3 +https://www.youtube.com/watch?v=Du0r5FOKbis: +- Bayonetta +https://www.youtube.com/watch?v=07mbmJHeBhg: +- Monopoly +https://www.youtube.com/watch?v=tPbnW2HmuIY: +- Super Mario Odyssey +https://www.youtube.com/watch?v=X6SVxsouuvE: +- Call of Duty Black Ops +- Black Ops +https://www.youtube.com/watch?v=zxgJTtumaV8: +- Tetris (Game Boy) - 10. Fanfare Victory (Varation) +https://www.youtube.com/watch?v=cOlCCc-_2sg: +- Dark Castle - Kirby's Dream Land 2 +https://www.youtube.com/watch?v=UDJtsfR51To: +- Xenoblade Chronicles OST - Gaur Plain +https://www.youtube.com/watch?v=tg-yC0xcI9s: +- Triggernometry - Red Dead Redemption Soundtrack +https://www.youtube.com/watch?v=rNNQBAj10n4: +- Marvel vs Capcom 3 +https://www.youtube.com/watch?v=bwhHD92bQUk: +- Sonic CD +https://www.youtube.com/watch?v=ZlTnLsjg59s: +- GTA V +- GTA 5 +- Grand Theft Auto 5 +https://www.youtube.com/watch?v=i-x22mnsUJ8: +- Gears 5 +- Gears of War 5 +https://www.youtube.com/watch?v=mOw3pZQctJ0: +- Call of Duty Advanced Warfare +https://www.youtube.com/watch?v=cnosICv2Mjg: +- Hotel Mario +https://www.youtube.com/watch?v=MMwM6hLEyeY: +- Sonic Mania +https://www.youtube.com/watch?v=XsliycURi0Y: +- Crackdown +https://www.youtube.com/watch?v=a51CkkWec1Q: +- Half-Life 2 +https://www.youtube.com/watch?v=iKBVXX8Vh7g: +- Call of Duty WWII +- Call of Duty WW2 +https://www.youtube.com/watch?v=qEvxbJROGuQ: +- Kirby Super Star +https://www.youtube.com/watch?v=_J4R6WsSKVs: +- Cuphead +https://www.youtube.com/watch?v=vcEGFAaSpzI: +- Final Fantasy VII +- Final Fantasy 7 +https://www.youtube.com/watch?v=cOWRNLaCMJg: +- Pokémon Red/Blue/Yellow +- Pokémon Red +- Pokemon Red +- Pokémon Blue +- Pokemon Blue +- Pokémon Yellow +- Pokemon Yellow +https://www.youtube.com/watch?v=XF2fA1epvLE: +- Super Mario World +https://www.youtube.com/watch?v=-tv0ZXF7uA4: +- GTA V +- GTA 5 +- Grand Theft Auto 5 +https://www.youtube.com/watch?v=3JncraxIMXY: +- Battlefield V +- Battlefield 5 +https://www.youtube.com/watch?v=fYnw_xoZCsg: +- Mario Teaches Typing 2 +https://www.youtube.com/watch?v=FQ73YtVm3Js: +- Spider-Man 2 The Game +- Spider Man 2 +- Spider-Man 2 +https://www.youtube.com/watch?v=nC5FK864qTk: +- Mortal Kombat Armageddon +https://www.youtube.com/watch?v=SnqPJv0UqKo: +- The Elder Scrolls 5 Skyrim +- Elder Scrolls 5 +- Skyrim +https://www.youtube.com/watch?v=pTE54izXTqc: +- Arkanoid R 2000 +- Arkanoid 2000 +- Arkanoid Returns +https://www.youtube.com/watch?v=FezNgPThD3M: +- Undertale +https://www.youtube.com/watch?v=DCX6c07rcJw: +- The World Ends With You +https://www.youtube.com/watch?v=hyQ6bn1Dno4: +- Sonic & All-Stars Racing Transformed +- Sonic and All Stars Racing Transformed +- Sonic and All-Stars Racing Transformed +- Sonic and Sega All-Stars Racing Transformed +- Sonic and Sega All Stars Racing Transformed +https://www.youtube.com/watch?v=okksreh2eQ4: +- Disney Universe +https://www.youtube.com/watch?v=3L97-DxwgRU: +- Billy The Wizard +https://www.youtube.com/watch?v=kY1F5QO5VOo: +- LittleBigPlanet +- Little Big Planet +https://www.youtube.com/watch?v=QEEPfzrDpoc: +- Bust a Groove +https://www.youtube.com/watch?v=JkO05j0zzZQ: +- LA Noire +https://www.youtube.com/watch?v=TNosg0EXQlU: +- Tom Clancys Rainbow Six Siege +- Rainbow Six Siege +- R6 Siege +https://www.youtube.com/watch?v=h38zN_vF_dQ: +- Fallout +https://www.youtube.com/watch?v=P9GX6Fyxr90: +- Snipperclips +https://www.youtube.com/watch?v=aKTJNO3HLEE: +- Xenoblade Chronicles +- Super Smash Bros Ultimate +https://www.youtube.com/watch?v=0gs56c9qGKA: +- Marvel Ultimate Alliance +https://www.youtube.com/watch?v=SzDmvY2pRJI: +- Yoshi's New Island +- Yoshis New Island +https://www.youtube.com/watch?v=IVH6Gl7W0hI: +- Super Mario Galaxy +https://www.youtube.com/watch?v=tZHcVMqXGhQ: +- Fire Emblem Echoes Shadows of Valentia +- Fire Emblem Echoes +- Shadows of Valentia +https://www.youtube.com/watch?v=Ltay2kMtCeM: +- Fortune Street +https://www.youtube.com/watch?v=mi3SE5j5XrU: +- Wizard101 +- Wizard 101 +https://www.youtube.com/watch?v=_nfld1C7RuE: +- Sonic and the Secret Rings +https://www.youtube.com/watch?v=nJB0OvU_vkg: +- Animal Crossing New Leaf +https://www.youtube.com/watch?v=xi0M9SIaLb4: +- Xenoblade Chronicles +https://www.youtube.com/watch?v=qw2lEHk5kDI: +- Gears of War 5 +- Gears 5 +https://www.youtube.com/watch?v=zlUWnUzpzSA: +- Halo 5 Guardians +https://www.youtube.com/watch?v=iUZyJHDasqA: +- Dark Souls II +- Dark Souls 2 +https://www.youtube.com/watch?v=jO3BZ9b1YsE: +- Nintendogs + Cats +- Nintendogs and Cats +https://www.youtube.com/watch?v=wDgQdr8ZkTw: +- Undertale +https://www.youtube.com/watch?v=O2MrJvaSjDE: +- Paper Mario Sticker Star +https://www.youtube.com/watch?v=umSJ--aGeYo: +- Portal +https://www.youtube.com/watch?v=JJOJdWubO-o: +- Plants vs Zombies Battle For Neighborville +- PvZ Battle for Neighborville +- Plants vs Zombies +https://www.youtube.com/watch?v=hUiAmrtxits: +- The Legend of Zelda The Wind Waker +- Zelda Wind Waker +- Zelda The Wind Waker +https://www.youtube.com/watch?v=1h9bU-y49-A: +- Hyper Street Fighter II The Anniversary Edition +- Hyper Street Fighter 2 +https://www.youtube.com/watch?v=sPWnlR5PEdQ: +- Call of Duty Advanced Warfare +https://www.youtube.com/watch?v=KF32DRg9opA: +- DuckTales +https://www.youtube.com/watch?v=rWatUe5yOP8: +- Sonic Heroes +https://www.youtube.com/watch?v=OTyghyyX34I: +- Paperboy 2 +https://www.youtube.com/watch?v=bGIP3IUgOg8: +- Super Mario Odyssey +https://www.youtube.com/watch?v=M8gJxIWSxGE: +- Battletoads +https://www.youtube.com/watch?v=btgi3TPL3AE: +- Castlevania +https://www.youtube.com/watch?v=rct233J9lsE: +- Dead Rising 2 Off The Record +- Dead Rising 2 +https://www.youtube.com/watch?v=Bz-QyMQDg_8: +- Life is Strange +https://www.youtube.com/watch?v=l2ETBzOAsV0: +- Sonic Forces +https://www.youtube.com/watch?v=6xzRa1EtOaw: +- Mario Strikers Charged +https://www.youtube.com/watch?v=6QpG48UEcI4: +- Fruit Ninja +https://www.youtube.com/watch?v=4n6WP9qHyRM: +- World of Warcraft +https://www.youtube.com/watch?v=R0cia14N9no: +- League of Legends +https://www.youtube.com/watch?v=iiNyQD5Yq3E: +- Star Wars Republic Commando +https://www.youtube.com/watch?v=nJ3o3kQZWm8: +- Parappa the Rapper +https://www.youtube.com/watch?v=2aV-ej4gwJU: +- Destiny 2 +https://www.youtube.com/watch?v=eCwzdl2q6Ho: +- King of Fighters XIII +- King of Fighters 13 +https://www.youtube.com/watch?v=PLDyWLbuptQ: +- Undertale +https://www.youtube.com/watch?v=3EdeEKDxhPE: +- Halo 3 +https://www.youtube.com/watch?v=em_oGE5Ht2Y: +- Resident Evil +https://www.youtube.com/watch?v=bQYuuUN7pSY: +- Mario Sports Mix +https://www.youtube.com/watch?v=cHJLdF2WNxI: +- World of Warcraft +https://www.youtube.com/watch?v=EXAb0Df3Rpc: +- Clannad +https://www.youtube.com/watch?v=LeeD7lWh2RE: +- Super Smash Bros Ultimate +https://www.youtube.com/watch?v=nJa7hJEfdww: +- Witcher 3 +https://www.youtube.com/watch?v=-cg5QXLS4nc: +- Cuphead +https://www.youtube.com/watch?v=8eDRM-YzpIs: +- Mario Sports Mix +https://www.youtube.com/watch?v=sunu9ShZ4xs: +- Mortal Kombat Armageddon +https://www.youtube.com/watch?v=n25nqibaIDg: +- Super Smash Bros Ultimate +https://www.youtube.com/watch?v=8KT7jcB72fQ: +- Donkey Kong 64 +https://www.youtube.com/watch?v=QCQ3DpI5hHk: +- Plants Vs Zombies +https://www.youtube.com/watch?v=Ze3G5EksIQw: +- The Stanley Parable +- Stanley Parable +https://www.youtube.com/watch?v=9pPQnhhUqtU: +- Total Distortion +https://www.youtube.com/watch?v=WSig9qC9tWE: +- Pokemon Sword and Shield +- Pokemon Sword +- Pokemon Shield +https://www.youtube.com/watch?v=5_E_y1AWAfc: +- Undertale +https://www.youtube.com/watch?v=TsfRhKE5iP8: +- Tomb Raider +https://www.youtube.com/watch?v=n5mo2zPBl3k: +- Middle Earth Shadow of Modor +- Shadow of Modor +https://www.youtube.com/watch?v=Z2g-xpGOusI: +- Tom Clancys The Division +- The Division +https://www.youtube.com/watch?v=S5DFhaprlRM: +- Super Smash Bros +https://www.youtube.com/watch?v=mtfsZv_wr98: +- Tekken +https://www.youtube.com/watch?v=Y8V_MvH8y8U: +- Fallout New Vegas +https://www.youtube.com/watch?v=4eGCH6tAycE: +- Xenoblade 2 +https://www.youtube.com/watch?v=HwJgHF0xDbU: +- Street Fighter V +- Street Fighter 5 +https://www.youtube.com/watch?v=taNV00DWLq8: +- ARMS +https://www.youtube.com/watch?v=9XzAPa4ji7M: +- Punch-Out!! +- Punch Out +- Punch-Out +https://www.youtube.com/watch?v=el_r5y_AcrE: +- Super Mario Sunshine +https://www.youtube.com/watch?v=7dRVuXYmB1M: +- Deltarune +https://www.youtube.com/watch?v=E_KMghGlDY8: +- Nintendo 3DS Music StreetPass Mii Plaza +- Nintendo 3DS +- StreetPass +- Mii Plaza +https://www.youtube.com/watch?v=QJbYjj5cNqQ: +- Wolfenstein The New Order +https://www.youtube.com/watch?v=qHpKdriwcOg: +- Kingdom Hearts +https://www.youtube.com/watch?v=jxMzCya8pQM: +- Payday 2 +https://www.youtube.com/watch?v=LQdUAMrcEnw: +- Farcry 5 +https://www.youtube.com/watch?v=r5Q4FOSPSx0: +- The Witcher 2 Assasins of Kings +- The Witcher 2 +https://www.youtube.com/watch?v=hU3K2a4uXxs: +- Sonic Unleashed +https://www.youtube.com/watch?v=2UKw9aheG3Y: +- Mafia 2 +https://www.youtube.com/watch?v=YTy9v9a7Tmo: +- Undertale +https://www.youtube.com/watch?v=rU_im8hx2TI: +- Earthbound +https://www.youtube.com/watch?v=sIL2fuxlMkc: +- Super Mario Galaxy +https://www.youtube.com/watch?v=-KBLgp5_smc: +- Mortal Kombat 9 +https://www.youtube.com/watch?v=Nsps0I58yUM: +- Dark Souls +https://www.youtube.com/watch?v=RjLWGyx4zoA: +- Persona 5 +https://www.youtube.com/watch?v=XXNVnq1r3yY: +- Castlevania Harmony of Despair +https://www.youtube.com/watch?v=6qIjHtI3kGU: +- Far Cry 5 +https://www.youtube.com/watch?v=3FRJU4DGZSc: +- Red Dead Redemption 2 +https://www.youtube.com/watch?v=zKYmW19Sk8s: +- Sekiro +https://www.youtube.com/watch?v=NYoP3_p2-N8: +- Super Mario 3D Land +https://www.youtube.com/watch?v=XnV_wHQ01dU: +- Cuphead +https://www.youtube.com/watch?v=MXAHCZXaoy8: +- Pokémon Red/Blue/Yellow +- Pokémon Red +- Pokemon Red +- Pokémon Blue +- Pokemon Blue +- Pokémon Yellow +- Pokemon Yellow +https://www.youtube.com/watch?v=56xw3mryadY: +- Mega Man 11 +https://www.youtube.com/watch?v=1K1rV9kFs6I: +- Sonic 3 +https://www.youtube.com/watch?v=7RiHmQcHa84: +- Shin Megami Tensei III Nocturne +- Shin Megami Tensei 3 Nocturne +- Shin Megami Tensei 3 +https://www.youtube.com/watch?v=aPGwniiEfIk: +- Escape from Mars Starring Taz +- Taz in Escape from Mars +- Escape from Mars +https://www.youtube.com/watch?v=kZnS_HxSdvo: +- Nintendo 3DS Find Mii +- Find Mii +https://www.youtube.com/watch?v=JEj_8VkTHVw: +- Mario + Rabbids Kingdom Battle +- Mario and Rabbids Kingdom Battle +https://www.youtube.com/watch?v=n8yKpcyU0fg: +- Hot Wheels Beat That +https://www.youtube.com/watch?v=cIKpAjB5B00: +- Super Hero Squad Online +https://www.youtube.com/watch?v=dYvvKyYIc5s: +- Touhou Embodiment of Scarlet Devil +- Touhou EoSD +- Embodiment of Scarlet Devil +https://www.youtube.com/watch?v=0BkHICB4eJs: +- Half Life +https://www.youtube.com/watch?v=dqwjST3UYcc: +- Mass Effect +https://www.youtube.com/watch?v=WQJxqXo8_cY: +- Tom Clancys Rainbow Six Siege +- Rainbow Six Siege +- R6 Siege +https://www.youtube.com/watch?v=4xbXFcadgy0: +- For Honor +https://www.youtube.com/watch?v=MJrZKCZ2pqo: +- Mario Kart Wii +https://www.youtube.com/watch?v=GgbXr4rN4D4: +- Banjo-Kazooie +- Banjo Kazooi +https://www.youtube.com/watch?v=oL5fbozc3kU: +- Yoshi's Island +- Yoshis Island +https://www.youtube.com/watch?v=DMDYpRaUvIQ: +- Sonic R +https://www.youtube.com/watch?v=oOMy4AZTPLc: +- Metropolis Street Racer +https://www.youtube.com/watch?v=Zf2qOWmKiz0: +- Deltarune +https://www.youtube.com/watch?v=6jFaoLrLzd4: +- Persona 3 +https://www.youtube.com/watch?v=UKYXOobaceQ: +- Sonic 3D Blast +https://www.youtube.com/watch?v=R9awS7E0jDc: +- Donkey Konga +https://www.youtube.com/watch?v=-VhWTfHZBOI: +- Contrast +https://www.youtube.com/watch?v=6QaH6tOXHek: +- Miitomo +https://www.youtube.com/watch?v=-GK41pLUuP0: +- New Super Mario Bros +https://www.youtube.com/watch?v=xjUHMsAZti4: +- Earthbound +https://www.youtube.com/watch?v=y4y13vnGXec: +- Psycho Soldier +https://www.youtube.com/watch?v=sEhg1qAmWMg: +- Codename Kids Next Door Operation V.I.D.E.O.G.A.M.E. +- Codename KND Operation VIDEOGAME +- Codename Kids Next Door Operation VIDEOGAME +- Codename Kids Next Door +https://www.youtube.com/watch?v=TAQVE2e3u8o: +- Power Rangers +https://www.youtube.com/watch?v=NRcsLULM2ns: +- Happy Wheels +https://www.youtube.com/watch?v=pU_zIsgnb3w: +- Hitman Blood Money +https://www.youtube.com/watch?v=ZXX8f0JcSG4: +- Pokémon Black & White +- Pokemon Black and White +- Pokemon Black +- Pokemon White +https://www.youtube.com/watch?v=AWkOMVUJ7gs: +- Fire Emblem Fates Condemnation +- Fire Emblem Fates +https://www.youtube.com/watch?v=QyPR77rg1to: +- Undertale +https://www.youtube.com/watch?v=sxJ-LSBfrQ4: +- Splatoon 2 +https://www.youtube.com/watch?v=RZVyHH-voR8: +- Dark Souls +https://www.youtube.com/watch?v=nWfRO1yxaXs: +- Wario world +- Warioworld +https://www.youtube.com/watch?v=bMF_PRSXbXk: +- Half-Life Opposing Force +https://www.youtube.com/watch?v=XVpC7Tg-hH4: +- Dreams +https://www.youtube.com/watch?v=EkLvDaWNez8: +- New Super Mario Bros +https://www.youtube.com/watch?v=7LvsWVE9-es: +- Pokémon X & Y +- Pokemon X +- Pokemon Y +https://www.youtube.com/watch?v=iAWXT2HJFTI: +- The Legend of Zelda Breath of the Wild +- Breath of the Wild +- Zelda Botw +https://www.youtube.com/watch?v=JZw9wWmcXH4: +- Super Smash Bros Brawl +https://www.youtube.com/watch?v=XJsy5jJ7Dp0: +- Trauma Center Under the Knife 2 +- Trauma Center 2 +- Trauma Center Under the Knife +https://www.youtube.com/watch?v=gd22rCkvJoA: +- Assassin's Creed 4 Black Flag +- Assasins Creed 4 +- Assasins Creed Black Flag +https://www.youtube.com/watch?v=ECE7ZeDsuPk: +- Destiny 2 +https://www.youtube.com/watch?v=v0nlnUDKT1s: +- Fallout 4 +https://www.youtube.com/watch?v=zgLgSQPN0Gw: +- Super Mario Party +https://www.youtube.com/watch?v=Ib3FTf3Frbo: +- Fire Emblem Three Houses +- Super Smash Bros Ultimate +https://www.youtube.com/watch?v=LG0-UMOljBU: +- Pokémon Gold/Silver/Crystal +- Pokemon Gold +- Pokemon Silver +- Pokemon Crystal +https://www.youtube.com/watch?v=0cuhPPuTA3s: +- Saints Row The Third +https://www.youtube.com/watch?v=1scgMvQoLVg: +- Tomodachi Life +https://www.youtube.com/watch?v=D8ShsHeYkeI: +- Dark Souls 2 +https://www.youtube.com/watch?v=BG-0WXsZR-A: +- Marvel vs Capcom 2 +https://www.youtube.com/watch?v=NPjLgDF1hUg: +- The Legend of Zelda Ocarina of Time +- Zelda Ocarina of Time +https://www.youtube.com/watch?v=upPtP_qtQHM: +- Super Paper Mario +https://www.youtube.com/watch?v=5xk8-dDqu50: +- Luigi's Mansion +- Luigis Mansion +https://www.youtube.com/watch?v=zop1cFVz1So: +- Killzone +https://www.youtube.com/watch?v=WLLE_PvJ5mY: +- Metroid Prime +https://www.youtube.com/watch?v=oMed0AQt6g4: +- Mass Effect +https://www.youtube.com/watch?v=S7NuuyVIYO8: +- Hitman +https://www.youtube.com/watch?v=ENWDVA0Xz88: +- Crackdown +https://www.youtube.com/watch?v=dQNAVqW1shA: +- Banjo-Kazooie +- Banjo Kazooie +https://www.youtube.com/watch?v=tnzziAe9tYA: +- Super Mario Galaxy 2 +https://www.youtube.com/watch?v=Ma7mdIHy-kA: +- Sonic Forces +https://www.youtube.com/watch?v=avyasO9uqfo: +- Donkey Kong Country +https://www.youtube.com/watch?v=i6v6w0mbPvU: +- A Hat in Time Music +https://www.youtube.com/watch?v=Df1nqnxRe7g: +- Left 4 Dead 2 +https://www.youtube.com/watch?v=QunjkirEY_g: +- Fire Emblem Fates +https://www.youtube.com/watch?v=-9bcB4_adYo: +- Crysis +https://www.youtube.com/watch?v=QMEqfXYjyvM: +- Deadspace +https://www.youtube.com/watch?v=a75SxyVBvKU: +- Half-Life 2 +- Half Life 2 +https://www.youtube.com/watch?v=5h1t94LxGd0: +- The Witcher 2 Assasins of Kings +- The Witcher 2 +https://www.youtube.com/watch?v=Eoo5oifOsUs: +- DayZ +https://www.youtube.com/watch?v=24ijaaCSrYM: +- For Honor +https://www.youtube.com/watch?v=sGjdjdyt5s4: +- Kirby Planet Robobot +https://www.youtube.com/watch?v=oA6kuCUmBpo: +- Captain Toad Treasure Tracker +https://www.youtube.com/watch?v=x9dJrEvHk3o: +- Mario & Sonic at the London 2012 Olympic Games +- Mario and Sonic at the London 2012 Olympic Games +https://www.youtube.com/watch?v=DheEtF1G_Rs: +- Splatoon 2 +https://www.youtube.com/watch?v=TcufdwbnF0k: +- Rhythm Heaven Fever +https://www.youtube.com/watch?v=4GChwuX81o4: +- Grabbed by the Ghoulies +https://www.youtube.com/watch?v=PhWge4ro354: +- Call of Duty Black Ops 2 +- Black Ops 2 +https://www.youtube.com/watch?v=bzbWrGr_of0: +- The Legend of Zelda Majora's Mask +- Zelda Majora's Mask +- Zelda Majoras Mask +https://www.youtube.com/watch?v=2RKnfOAI1A0: +- 3D Dot Game Heroes +https://www.youtube.com/watch?v=L2f5v1w_v8g: +- Conker Live & Reloaded +- Conker Live and Reloaded +https://www.youtube.com/watch?v=nVRQJ2i59XE: +- Wii Sports +https://www.youtube.com/watch?v=JDqJa1RC3q8: +- Kid Icarus Uprising +https://www.youtube.com/watch?v=Jq6oRi6UTXY: +- Sonic the Hedgehog 3 +https://www.youtube.com/watch?v=JzzJwrIN6Mc: +- Mega Man 3 +https://www.youtube.com/watch?v=s1AkaZuRaM4: +- Sonic Dash +https://www.youtube.com/watch?v=7S7WEkopK8k: +- Dance Dance Revolution Mario Mix +- Dance Dance Revolution Mario +https://www.youtube.com/watch?v=KVwdKcwWe2k: +- Yooka-Laylee Music +- Yooka Laylee +https://www.youtube.com/watch?v=ZdBO_V-guJ4: +- Call of Duty Modern Warfare +- Modern Warfare +https://www.youtube.com/watch?v=NQIeAbFT4Kc: +- League of Legends +https://www.youtube.com/watch?v=cnAoE7tcrBI: +- Max Payne +https://www.youtube.com/watch?v=pYf0C4Yh8pE: +- Apex Legends +https://www.youtube.com/watch?v=0W-trrXXcS8: +- Far Cry New Dawn +https://www.youtube.com/watch?v=jv249KSsjcM: +- Red Dead Redemption 2 +https://www.youtube.com/watch?v=z5JhMEP0GSU: +- Mario Party The Top 100 +- Mario Party Top 100 +https://www.youtube.com/watch?v=3nxFYoGNJKc: +- Banjo-Kazooie +- Banjo Kazooie +https://www.youtube.com/watch?v=6vICKfFo6bU: +- Wii Fit +https://www.youtube.com/watch?v=EJCq_hnFZyk: +- Fire Emblem Three Houses +https://www.youtube.com/watch?v=bXM7CvKCSx0: +- GTA San Andreas +- Grand Theft Auto San Andreas +https://www.youtube.com/watch?v=5YjGmTI_fPU: +- Bayonetta +https://www.youtube.com/watch?v=5wApmRF5gyM: +- Super Mario Bros 3 +https://www.youtube.com/watch?v=kRMTqfxkCsc: +- Mortal Kombat Deadly Alliance +https://www.youtube.com/watch?v=Ljqe4Nj7nBA: +- The Legend of Zelda Ocarina of Time +- Zelda Ocarina of Time +https://www.youtube.com/watch?v=RB4nFoA63rs: +- Gravity Rush +https://www.youtube.com/watch?v=UsaoAl5EIH8: +- Punch-Out!! +- Punch Out +- Punch-Out +https://www.youtube.com/watch?v=rMudHClToL0: +- Donkey Kong Country Tropical Freeze +- Donkey Kong Tropical Freeze +https://www.youtube.com/watch?v=iT8T-dBwztg: +- FlingSmash +https://www.youtube.com/watch?v=dcp5gPf7mhI: +- Jet Set Radio Future +https://www.youtube.com/watch?v=sC0cvwnG0Ik: +- Crazy Bus +https://www.youtube.com/watch?v=KJfMS5XzmIc: +- Animal Crossing New Horizons +https://www.youtube.com/watch?v=HAYPkVeWjXM: +- Halo 5 Guardians +https://www.youtube.com/watch?v=ozLqx3EboU4: +- Shantae Half-Genie Hero +- Shantae Half Genie Hero +- 'Shantae 1/2 Genie Hero' +https://www.youtube.com/watch?v=e2Gyaqf7EoU: +- Persona 3 +https://www.youtube.com/watch?v=KC5kHf58GMI: +- The Legend of Zelda Skyward Sword +- Skyward Sword +https://www.youtube.com/watch?v=XHzlfCpmqsw: +- Super Mario Kart +https://www.youtube.com/watch?v=6l1HUDuzyrk: +- Sonic Lost World +https://www.youtube.com/watch?v=512pcIZOjm0: +- Xenoblade Chronicles +https://www.youtube.com/watch?v=7ePFxuu9lik: +- The Walking Dead Game +- The Walking Dead +https://www.youtube.com/watch?v=rgq0wF_ByO8: +- Contrast +https://www.youtube.com/watch?v=O7eGxoFLOI4: +- Mario Kart Double Dash!! +- Mario Kart Double Dash +https://www.youtube.com/watch?v=6ppBSY92rzg: +- EarthBound +https://www.youtube.com/watch?v=mmiWjG0N21w: +- Kingdom Hearts +https://www.youtube.com/watch?v=kNlVo5udnPs: +- Team Fortress 2 +https://www.youtube.com/watch?v=-t_C-CgJ-3A: +- Pokemon Sun & Moon +- Pokemon Sun +- Pokemon Moon +https://www.youtube.com/watch?v=7G_aaak-tDE: +- The Legend of Zelda Majora's Mask +- Zelda Majora's Mask +- Zelda Majoras Mask +https://www.youtube.com/watch?v=rGFic2e-uTE: +- Kirby's Epic Yarn +- Kirbys Epic Yarn +https://www.youtube.com/watch?v=1Cfin7GJUGI: +- Mario vs Donkey Kong 2 March of the Minis +- Mario vs Donkey Kong 2 +https://www.youtube.com/watch?v=gtKxgf_lETg: +- Rhythm Heaven Fever +https://www.youtube.com/watch?v=xnmxhE9usbI: +- Animal Crossing +https://www.youtube.com/watch?v=kwQ6_rwvMp8: +- ARMS +https://www.youtube.com/watch?v=2odP8HQuPgs: +- Nintendogs +https://www.youtube.com/watch?v=9ICz9BArKiM: +- Splatoon 2 +https://www.youtube.com/watch?v=IeosKeE1psE: +- Super Street Fighter IV +- Super Street Fighter 4 +https://www.youtube.com/watch?v=P9lLD_hgbU4: +- Bayonetta +https://www.youtube.com/watch?v=QTRsbrFWomM: +- Fire Emblem Fates +https://www.youtube.com/watch?v=zwOnn-ObtNo: +- Chibi-Robo! +- Chibi-Robo +- Chibi Robo +https://www.youtube.com/watch?v=5FYneJvqtMc: +- Lego Dimensions +https://www.youtube.com/watch?v=jkk6f817nNU: +- Xenoblade Chronicles +https://www.youtube.com/watch?v=N8OHSXvneOE: +- Celeste +https://www.youtube.com/watch?v=ryAv7ESrJtQ: +- Half Life 2 +https://www.youtube.com/watch?v=vkailb3xcTI: +- Super Mario RPG +https://www.youtube.com/watch?v=MOkWNuGfCD0: +- Pokémon X & Y +- Pokemon X +- Pokemon Y +https://www.youtube.com/watch?v=JUYAX3z2JYc: +- Call of Duty Black Ops 2 +- Black Ops 2 +https://www.youtube.com/watch?v=kfo7hmy-qso: +- Bomberman Hero +https://www.youtube.com/watch?v=myxM9Q72hxE: +- NES Remix Pack +https://www.youtube.com/watch?v=PbYoMpwHq5c: +- Wii Play +https://www.youtube.com/watch?v=-NuIPxOmpmc: +- Star Fox +https://www.youtube.com/watch?v=Ga9wY3CzEJQ: +- Gex Enter the Gecko +- Gex +https://www.youtube.com/watch?v=mpqct1zMqTE: +- Killer Instinct S1 +- Killer Instinct +https://www.youtube.com/watch?v=g3jCAyPai2Y: +- Yakuza +https://www.youtube.com/watch?v=4GfXKM-2nak: +- Paper Mario The Origami King +https://www.youtube.com/watch?v=6rW52ajkrZ0: +- Street Fighter V +- Street Fighter 5 +https://www.youtube.com/watch?v=zLs96hgloOA: +- Sekiro +https://www.youtube.com/watch?v=qG_lzlLH3UU: +- Titanfall +https://www.youtube.com/watch?v=U8ilLSEjlAQ: +- Destiny 2 +https://www.youtube.com/watch?v=QFaZC_R3WoE: +- Resident Evil 3 +https://www.youtube.com/watch?v=1fmhHRNzZLo: +- Gears Of War +https://www.youtube.com/watch?v=Oc44myRhNRM: +- Pokemon Omega Ruby/Alpha Sapphire +- Pokemon Omega Ruby +- Pokemon Alpha Sapphire +https://www.youtube.com/watch?v=vNVz6N-bNZs: +- Sonic Adventure +https://www.youtube.com/watch?v=WcabIuZOWYY: +- DragonBall Xenoverse 2 +https://www.youtube.com/watch?v=GmpB0btoC6M: +- Yoshi's Cookie +- Yoshis Cookie +https://www.youtube.com/watch?v=KC-B3Q0cRrY: +- Streets Of Rage 2 +https://www.youtube.com/watch?v=DekKId4f-Yk: +- Super Mario 3D World +https://www.youtube.com/watch?v=xorWgC8vf3I: +- Fallout 3 +https://www.youtube.com/watch?v=X9Q4Zg1tYno: +- Super Smash Bros Wii U +https://www.youtube.com/watch?v=llOvd2yGEio: +- Team Fortress 2 +https://www.youtube.com/watch?v=RR_ifniLcW4: +- Donkey Kong Country 2 Diddy's Kong Quest +- Donkey Kong Country 2 +- Diddys Kong Quest +https://www.youtube.com/watch?v=NoBRdekWKxI: +- The Legend of Zelda Twilight Princess +- Twilight Princess +https://www.youtube.com/watch?v=rpocm85Hauc: +- Crash Tag Team Racing +https://www.youtube.com/watch?v=-4FvqXfQYbk: +- Dragon Ball FighterZ +https://www.youtube.com/watch?v=xgcyH9I1NBE: +- Sonic Adventure +https://www.youtube.com/watch?v=gESYyh_tsGA: +- Halo Infinite +https://www.youtube.com/watch?v=fXWCotTeNgk: +- Donkey Kong 64 +https://www.youtube.com/watch?v=tWBKtLiYe5w: +- Persona 4 Dancing All Night +- Persona 4 +https://www.youtube.com/watch?v=pQ-bjZD1EnI: +- Sonic Heroes +https://www.youtube.com/watch?v=4S9qb1jZGlA: +- Ghost of Tsushima +https://www.youtube.com/watch?v=v1M8VVDFxgo: +- Celeste +https://www.youtube.com/watch?v=ROBc_MVW-Lg: +- Fall Guys Ultimate Knockout +- Fall Guys +https://www.youtube.com/watch?v=r5EjvtyLJ1g: +- Sonic the Hedgehog CD +- Sonic CD +https://www.youtube.com/watch?v=JRPXRHS4XNQ: +- Battletoads +https://www.youtube.com/watch?v=hHKmORyO8WA: +- Mario Super Sluggers +https://www.youtube.com/watch?v=oeEiOtndLB0: +- Crusader Kings 2 +https://www.youtube.com/watch?v=TKvjEQXKeec: +- Earthbound +https://www.youtube.com/watch?v=m65ns26m9IE: +- A Hat in Time +https://www.youtube.com/watch?v=feM7-QL9x38: +- Bomberman Max 2 +https://www.youtube.com/watch?v=EGeoG0KQa68: +- Mappy Arcade +- Mappy +https://www.youtube.com/watch?v=ySYwqTKRtgQ: +- Mega Man 2 +https://www.youtube.com/watch?v=VCCZe8S5miY: +- Pac-Man World +- Pac Man World +https://www.youtube.com/watch?v=g-aNgman8Wo: +- Street Fighter 5 +https://www.youtube.com/watch?v=U1_PStq_tTM: +- Watch Dogs +https://www.youtube.com/watch?v=vLljFmx7yF8: +- Counter-Strike Global Offensive +- CS GO +- CSGO +- Counter Strike GO +- Counter Strike Global Offensive +https://www.youtube.com/watch?v=gsZJg5mmsHA: +- The Angry Video Game Nerd Adventures +- Angry Video Game Nerd Adventures +https://www.youtube.com/watch?v=n3c2xbYWKY0: +- Double Dragon +https://www.youtube.com/watch?v=JNEKqu6Pe48: +- Pac Man Party +- Pac-Man Party +https://www.youtube.com/watch?v=sEFIZh_Zscc: +- Bioshock +https://www.youtube.com/watch?v=fmU9MXi9Uz0: +- Under Night In-Birth +- Under Night In Birth +https://www.youtube.com/watch?v=EzDLLILqvLU: +- Asura's Wrath +- Asuras Wrath diff --git a/audiotrivia/data/lists/games.yaml b/audiotrivia/data/lists/non_funcitonal_lists/games.yaml similarity index 99% rename from audiotrivia/data/lists/games.yaml rename to audiotrivia/data/lists/non_funcitonal_lists/games.yaml index c3a9078..da1dcb6 100644 --- a/audiotrivia/data/lists/games.yaml +++ b/audiotrivia/data/lists/non_funcitonal_lists/games.yaml @@ -1,13 +1,14 @@ AUTHOR: Plab -https://www.youtube.com/watch?v=--bWm9hhoZo: +NEEDS: New links for all songs. +https://www.youtube.com/watch?v=f9O2Rjn1azc: - Transistor -https://www.youtube.com/watch?v=-4nCbgayZNE: +https://www.youtube.com/watch?v=PgUhYFkVdSY: - Dark Cloud 2 - Dark Cloud II -https://www.youtube.com/watch?v=-64NlME4lJU: +https://www.youtube.com/watch?v=1T1RZttyMwU: - Mega Man 7 - Mega Man VII -https://www.youtube.com/watch?v=-AesqnudNuw: +https://www.youtube.com/watch?v=AdDbbzuq1vY: - Mega Man 9 - Mega Man IX https://www.youtube.com/watch?v=-BmGDtP2t7M: diff --git a/ccrole/ccrole.py b/ccrole/ccrole.py index bd41a67..59efc55 100644 --- a/ccrole/ccrole.py +++ b/ccrole/ccrole.py @@ -1,4 +1,5 @@ import asyncio +import logging import re import discord @@ -6,14 +7,16 @@ from discord.ext.commands.view import StringView from redbot.core import Config, checks, commands from redbot.core.bot import Red from redbot.core.utils.chat_formatting import box, pagify +from redbot.core.utils.mod import get_audit_reason + +log = logging.getLogger("red.fox_v3.ccrole") async def _get_roles_from_content(ctx, content): content_list = content.split(",") try: role_list = [ - discord.utils.get(ctx.guild.roles, name=role.strip(" ")).id - for role in content_list + discord.utils.get(ctx.guild.roles, name=role.strip(" ")).id for role in content_list ] except (discord.HTTPException, AttributeError): # None.id is attribute error return None @@ -55,6 +58,12 @@ class CCRole(commands.Cog): When adding text, put arguments in `{}` to eval them Options: `{author}`, `{target}`, `{server}`, `{channel}`, `{message}`""" + + # TODO: Clean this up so it's not so repetitive + # The call/answer format has better options as well + # Saying "none" over and over can trigger automod actions as well + # Also, allow `ctx.tick()` instead of sending a message + command = command.lower() if command in self.bot.all_commands: await ctx.send("That command is already a standard command.") @@ -76,7 +85,8 @@ class CCRole(commands.Cog): # Roles to add await ctx.send( - "What roles should it add? (Must be **comma separated**)\nSay `None` to skip adding roles" + "What roles should it add? (Must be **comma separated**)\n" + "Say `None` to skip adding roles" ) def check(m): @@ -97,7 +107,8 @@ class CCRole(commands.Cog): # Roles to remove await ctx.send( - "What roles should it remove? (Must be comma separated)\nSay `None` to skip removing roles" + "What roles should it remove? (Must be comma separated)\n" + "Say `None` to skip removing roles" ) try: answer = await self.bot.wait_for("message", timeout=120, check=check) @@ -114,7 +125,8 @@ class CCRole(commands.Cog): # Roles to use await ctx.send( - "What roles are allowed to use this command? (Must be comma separated)\nSay `None` to allow all roles" + "What roles are allowed to use this command? (Must be comma separated)\n" + "Say `None` to allow all roles" ) try: @@ -131,7 +143,9 @@ class CCRole(commands.Cog): return # Selfrole - await ctx.send("Is this a targeted command?(yes/no)\nNo will make this a selfrole command") + await ctx.send( + "Is this a targeted command?(yes/no)\n" "No will make this a selfrole command" + ) try: answer = await self.bot.wait_for("message", timeout=120, check=check) @@ -149,7 +163,7 @@ class CCRole(commands.Cog): # Message to send await ctx.send( "What message should the bot say when using this command?\n" - "Say `None` to send the default `Success!` message\n" + "Say `None` to send no message and just react with ✅\n" "Eval Options: `{author}`, `{target}`, `{server}`, `{channel}`, `{message}`\n" "For example: `Welcome {target.mention} to {server.name}!`" ) @@ -160,7 +174,7 @@ class CCRole(commands.Cog): await ctx.send("Timed out, canceling") return - text = "Success!" + text = None if answer.content.upper() != "NONE": text = answer.content @@ -193,7 +207,7 @@ class CCRole(commands.Cog): await self.config.guild(guild).cmdlist.set_raw(command, value=None) await ctx.send("Custom command successfully deleted.") - @ccrole.command(name="details") + @ccrole.command(name="details", aliases=["detail"]) async def ccrole_details(self, ctx, command: str): """Provide details about passed custom command""" guild = ctx.guild @@ -217,10 +231,10 @@ class CCRole(commands.Cog): [discord.utils.get(ctx.guild.roles, id=roleid).name for roleid in role_list] ) - embed.add_field(name="Text", value="```{}```".format(cmd["text"])) - embed.add_field(name="Adds Roles", value=process_roles(cmd["aroles"]), inline=True) - embed.add_field(name="Removes Roles", value=process_roles(cmd["rroles"]), inline=True) - embed.add_field(name="Role Restrictions", value=process_roles(cmd["proles"]), inline=True) + embed.add_field(name="Text", value="```{}```".format(cmd["text"]), inline=False) + embed.add_field(name="Adds Roles", value=process_roles(cmd["aroles"]), inline=False) + embed.add_field(name="Removes Roles", value=process_roles(cmd["rroles"]), inline=False) + embed.add_field(name="Role Restrictions", value=process_roles(cmd["proles"]), inline=False) await ctx.send(embed=embed) @@ -288,6 +302,8 @@ class CCRole(commands.Cog): if cmd is not None: await self.eval_cc(cmd, message, ctx) + else: + log.debug(f"No custom command named {ctx.invoked_with} found") async def get_prefix(self, message: discord.Message) -> str: """ @@ -312,18 +328,10 @@ class CCRole(commands.Cog): if cmd["proles"] and not ( set(role.id for role in message.author.roles) & set(cmd["proles"]) ): + log.debug(f"{message.author} missing required role to execute {ctx.invoked_with}") return # Not authorized, do nothing if cmd["targeted"]: - # try: - # arg1 = message.content.split(maxsplit=1)[1] - # except IndexError: # .split() return list of len<2 - # target = None - # else: - # target = discord.utils.get( - # message.guild.members, mention=arg1 - # ) - view: StringView = ctx.view view.skip_ws() @@ -342,47 +350,43 @@ class CCRole(commands.Cog): else: target = None - # try: - # arg1 = ctx.args[1] - # except IndexError: # args is list of len<2 - # target = None - # else: - # target = discord.utils.get( - # message.guild.members, mention=arg1 - # ) - if not target: - out_message = "This custom command is targeted! @mention a target\n`{} `".format( - ctx.invoked_with + out_message = ( + f"This custom command is targeted! @mention a target\n`" + f"{ctx.invoked_with} `" ) - await message.channel.send(out_message) + await ctx.send(out_message) return else: target = message.author + reason = get_audit_reason(message.author) + if cmd["aroles"]: arole_list = [ discord.utils.get(message.guild.roles, id=roleid) for roleid in cmd["aroles"] ] - # await self.bot.send_message(message.channel, "Adding: "+str([str(arole) for arole in arole_list])) try: - await target.add_roles(*arole_list) + await target.add_roles(*arole_list, reason=reason) except discord.Forbidden: - await message.channel.send("Permission error: Unable to add roles") - await asyncio.sleep(1) + log.exception(f"Permission error: Unable to add roles") + await ctx.send("Permission error: Unable to add roles") if cmd["rroles"]: rrole_list = [ discord.utils.get(message.guild.roles, id=roleid) for roleid in cmd["rroles"] ] - # await self.bot.send_message(message.channel, "Removing: "+str([str(rrole) for rrole in rrole_list])) try: - await target.remove_roles(*rrole_list) + await target.remove_roles(*rrole_list, reason=reason) except discord.Forbidden: - await message.channel.send("Permission error: Unable to remove roles") + log.exception(f"Permission error: Unable to remove roles") + await ctx.send("Permission error: Unable to remove roles") - out_message = self.format_cc(cmd, message, target) - await message.channel.send(out_message, allowed_mentions=discord.AllowedMentions()) + if cmd["text"] is not None: + out_message = self.format_cc(cmd, message, target) + await ctx.send(out_message, allowed_mentions=discord.AllowedMentions()) + else: + await ctx.tick() def format_cc(self, cmd, message, target): out = cmd["text"] @@ -396,6 +400,7 @@ class CCRole(commands.Cog): """ For security reasons only specific objects are allowed Internals are ignored + Copied from customcom.CustomCommands.transform_parameter and added `target` """ raw_result = "{" + result + "}" objects = { diff --git a/chatter/README.md b/chatter/README.md index e8c03d6..8ef6734 100644 --- a/chatter/README.md +++ b/chatter/README.md @@ -29,7 +29,7 @@ Chatter by default uses spaCy's `en_core_web_md` training model, which is ~50 MB Chatter can potential use spaCy's `en_core_web_lg` training model, which is ~800 MB -Chatter uses as sqlite database that can potentially take up a large amount os disk space, +Chatter uses as sqlite database that can potentially take up a large amount of disk space, depending on how much training Chatter has done. The sqlite database can be safely deleted at any time. Deletion will only erase training data. @@ -50,7 +50,9 @@ Linux is a bit easier, but only tested on Debian and Ubuntu. ## Windows Prerequisites -Install these on your windows machine before attempting the installation +**Requires 64 Bit Python to continue on Windows.** + +Install these on your windows machine before attempting the installation: [Visual Studio C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) @@ -83,6 +85,7 @@ pip install --no-deps "chatterbot>=1.1" #### Step 3: Load Chatter ``` +[p]repo add Fox https://github.com/bobloy/Fox-V3 # If you didn't already do this in step 1 [p]cog install Fox chatter [p]load chatter ``` @@ -92,7 +95,7 @@ pip install --no-deps "chatterbot>=1.1" #### Step 1: Built-in Downloader ``` -[p]cog install Chatter +[p]cog install Chatter ``` #### Step 2: Install Requirements diff --git a/chatter/chat.py b/chatter/chat.py index a464e40..ef75bb8 100644 --- a/chatter/chat.py +++ b/chatter/chat.py @@ -3,6 +3,7 @@ import logging import os import pathlib from datetime import datetime, timedelta +from typing import Optional import discord from chatterbot import ChatBot @@ -17,6 +18,13 @@ from redbot.core.utils.predicates import MessagePredicate log = logging.getLogger("red.fox_v3.chatter") +def my_local_get_prefix(prefixes, content): + for p in prefixes: + if content.startswith(p): + return p + return None + + class ENG_LG: ISO_639_1 = "en_core_web_lg" ISO_639 = "eng" @@ -45,7 +53,7 @@ class Chatter(Cog): self.bot = bot self.config = Config.get_conf(self, identifier=6710497116116101114) default_global = {} - default_guild = {"whitelist": None, "days": 1, "convo_delta": 15} + default_guild = {"whitelist": None, "days": 1, "convo_delta": 15, "chatchannel": None} path: pathlib.Path = cog_data_path(self) self.data_path = path / "database.sqlite3" @@ -183,6 +191,25 @@ class Chatter(Cog): if ctx.invoked_subcommand is None: pass + @chatter.command(name="channel") + async def chatter_channel( + self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None + ): + """ + Set a channel that the bot will respond in without mentioning it + + Pass with no channel object to clear this guild's channel + """ + if channel is None: + await self.config.guild(ctx.guild).chatchannel.set(None) + await ctx.maybe_send_embed("Chat channel for guild is cleared") + else: + if channel.guild != ctx.guild: + await ctx.maybe_send_embed("What are you trying to pull here? :eyes:") + return + await self.config.guild(ctx.guild).chatchannel.set(channel.id) + await ctx.maybe_send_embed(f"Chat channel is now {channel.mention}") + @chatter.command(name="cleardata") async def chatter_cleardata(self, ctx: commands.Context, confirm: bool = False): """ @@ -407,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 @@ -434,24 +461,21 @@ class Chatter(Cog): ########### # Thank you Cog-Creators + channel: discord.TextChannel = message.channel - def my_local_get_prefix(prefixes, content): - for p in prefixes: - if content.startswith(p): - return p - return None - - when_mentionables = commands.when_mentioned(self.bot, message) + if guild is not None and channel.id == await self.config.guild(guild).chatchannel(): + pass # good to go + else: + when_mentionables = commands.when_mentioned(self.bot, message) - prefix = my_local_get_prefix(when_mentionables, message.content) + prefix = my_local_get_prefix(when_mentionables, message.content) - if prefix is None: - # print("not mentioned") - return + if prefix is None: + # print("not mentioned") + return - channel: discord.TextChannel = message.channel + message.content = message.content.replace(prefix, "", 1) - message.content = message.content.replace(prefix, "", 1) text = message.clean_content async with channel.typing(): diff --git a/chatter/info.json b/chatter/info.json index e9749ff..b79e587 100644 --- a/chatter/info.json +++ b/chatter/info.json @@ -5,7 +5,7 @@ "min_bot_version": "3.4.0", "description": "Create an offline chatbot that talks like your average member using Machine Learning", "hidden": false, - "install_msg": "Thank you for installing Chatter! Get started ith `[p]load chatter` and `[p]help Chatter`", + "install_msg": "Thank you for installing Chatter! Please make sure you check the install instructions at https://github.com/bobloy/Fox-V3/blob/master/chatter/README.md\nAfter that, get started ith `[p]load chatter` and `[p]help Chatter`", "requirements": [ "git+git://github.com/gunthercox/chatterbot-corpus@master#egg=chatterbot_corpus", "mathparse>=0.1,<0.2", diff --git a/dad/dad.py b/dad/dad.py index edf98c5..0be7fce 100644 --- a/dad/dad.py +++ b/dad/dad.py @@ -85,6 +85,8 @@ class Dad(Cog): @commands.Cog.listener() async def on_message_without_command(self, message: discord.Message): + if message.author.bot: + return guild: discord.Guild = getattr(message, "guild", None) if guild is None: return diff --git a/fifo/__init__.py b/fifo/__init__.py new file mode 100644 index 0000000..34cfd7b --- /dev/null +++ b/fifo/__init__.py @@ -0,0 +1,11 @@ +from .fifo import FIFO + + +async def setup(bot): + cog = FIFO(bot) + bot.add_cog(cog) + await cog.initialize() + + +def teardown(bot): + pass diff --git a/fifo/datetime_cron_converters.py b/fifo/datetime_cron_converters.py new file mode 100644 index 0000000..9e01cc8 --- /dev/null +++ b/fifo/datetime_cron_converters.py @@ -0,0 +1,42 @@ +from datetime import datetime, tzinfo +from typing import TYPE_CHECKING + +from apscheduler.triggers.cron import CronTrigger +from dateutil import parser +from discord.ext.commands import BadArgument, Converter +from pytz import timezone + +from fifo.timezones import assemble_timezones + +if TYPE_CHECKING: + DatetimeConverter = datetime + CronConverter = str +else: + + class TimezoneConverter(Converter): + async def convert(self, ctx, argument) -> tzinfo: + tzinfos = assemble_timezones() + if argument.upper() in tzinfos: + return tzinfos[argument.upper()] + + timez = timezone(argument) + + if timez is not None: + return timez + raise BadArgument() + + class DatetimeConverter(Converter): + async def convert(self, ctx, argument) -> datetime: + dt = parser.parse(argument, fuzzy=True, tzinfos=assemble_timezones()) + if dt is not None: + return dt + raise BadArgument() + + class CronConverter(Converter): + async def convert(self, ctx, argument) -> str: + try: + CronTrigger.from_crontab(argument) + except ValueError: + raise BadArgument() + + return argument diff --git a/fifo/fifo.py b/fifo/fifo.py new file mode 100644 index 0000000..acd01ac --- /dev/null +++ b/fifo/fifo.py @@ -0,0 +1,511 @@ +import logging +from datetime import datetime, timedelta, tzinfo +from typing import Optional, Union + +import discord +from apscheduler.job import Job +from apscheduler.jobstores.base import JobLookupError +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.schedulers.base import STATE_PAUSED, STATE_RUNNING +from redbot.core import Config, checks, commands +from redbot.core.bot import Red +from redbot.core.commands import TimedeltaConverter +from redbot.core.utils.chat_formatting import pagify + +from .datetime_cron_converters import CronConverter, DatetimeConverter, TimezoneConverter +from .task import Task + +schedule_log = logging.getLogger("red.fox_v3.fifo.scheduler") +schedule_log.setLevel(logging.DEBUG) + +log = logging.getLogger("red.fox_v3.fifo") + + +async def _execute_task(task_state): + log.info(f"Executing {task_state=}") + task = Task(**task_state) + if await task.load_from_config(): + return await task.execute() + return False + + +def _assemble_job_id(task_name, guild_id): + return f"{task_name}_{guild_id}" + + +def _disassemble_job_id(job_id: str): + return job_id.split("_") + + +class FIFO(commands.Cog): + """ + Simple Scheduling Cog + + Named after the simplest scheduling algorithm: First In First Out + """ + + def __init__(self, bot: Red): + super().__init__() + self.bot = bot + self.config = Config.get_conf(self, identifier=70737079, force_registration=True) + + default_global = {"jobs": []} + default_guild = {"tasks": {}} + + self.config.register_global(**default_global) + self.config.register_guild(**default_guild) + + self.scheduler = None + self.jobstore = None + + self.tz_cog = None + + async def red_delete_data_for_user(self, **kwargs): + """Nothing to delete""" + return + + def cog_unload(self): + # self.scheduler.remove_all_jobs() + if self.scheduler is not None: + self.scheduler.shutdown() + + async def initialize(self): + + job_defaults = {"coalesce": False, "max_instances": 1} + + # executors = {"default": AsyncIOExecutor()} + + # Default executor is already AsyncIOExecutor + self.scheduler = AsyncIOScheduler(job_defaults=job_defaults, logger=schedule_log) + + from .redconfigjobstore import RedConfigJobStore + + self.jobstore = RedConfigJobStore(self.config, self.bot) + await self.jobstore.load_from_config(self.scheduler, "default") + self.scheduler.add_jobstore(self.jobstore, "default") + + self.scheduler.start() + + async def _check_parsable_command(self, ctx: commands.Context, command_to_parse: str): + message: discord.Message = ctx.message + + message.content = ctx.prefix + command_to_parse + message.author = ctx.author + + new_ctx: commands.Context = await self.bot.get_context(message) + + return new_ctx.valid + + async def _delete_task(self, task: Task): + job: Union[Job, None] = await self._get_job(task) + if job is not None: + job.remove() + + await task.delete_self() + + async def _process_task(self, task: Task): + job: Union[Job, None] = await self._get_job(task) + if job is not None: + job.reschedule(await task.get_combined_trigger()) + return job + return await self._add_job(task) + + async def _get_job(self, task: Task) -> Job: + return self.scheduler.get_job(_assemble_job_id(task.name, task.guild_id)) + + async def _add_job(self, task: Task): + return self.scheduler.add_job( + _execute_task, + args=[task.__getstate__()], + id=_assemble_job_id(task.name, task.guild_id), + trigger=await task.get_combined_trigger(), + ) + + async def _resume_job(self, task: Task): + try: + job = self.scheduler.resume_job(job_id=_assemble_job_id(task.name, task.guild_id)) + except JobLookupError: + job = await self._process_task(task) + return job + + async def _pause_job(self, task: Task): + return self.scheduler.pause_job(job_id=_assemble_job_id(task.name, task.guild_id)) + + async def _remove_job(self, task: Task): + return self.scheduler.remove_job(job_id=_assemble_job_id(task.name, task.guild_id)) + + async def _get_tz(self, user: Union[discord.User, discord.Member]) -> Union[None, tzinfo]: + if self.tz_cog is None: + self.tz_cog = self.bot.get_cog("Timezone") + if self.tz_cog is None: + self.tz_cog = False # only try once to get the timezone cog + + if not self.tz_cog: + return None + try: + usertime = await self.tz_cog.config.user(user).usertime() + except AttributeError: + return None + + if usertime: + return await TimezoneConverter().convert(None, usertime) + else: + return None + + @checks.is_owner() + @commands.guild_only() + @commands.command() + async def fifoclear(self, ctx: commands.Context): + """Debug command to clear all current fifo data""" + self.scheduler.remove_all_jobs() + await self.config.guild(ctx.guild).tasks.clear() + await self.config.jobs.clear() + # await self.config.jobs_index.clear() + await ctx.tick() + + @checks.is_owner() # Will be reduced when I figure out permissions later + @commands.guild_only() + @commands.group() + async def fifo(self, ctx: commands.Context): + """ + Base command for handling scheduling of tasks + """ + if ctx.invoked_subcommand is None: + pass + + @fifo.command(name="set") + async def fifo_set( + self, + ctx: commands.Context, + task_name: str, + author_or_channel: Union[discord.Member, discord.TextChannel], + ): + """ + Sets a different author or in a different channel for execution of a task. + """ + task = Task(task_name, ctx.guild.id, self.config, bot=self.bot) + await task.load_from_config() + + if task.data is None: + await ctx.maybe_send_embed( + f"Task by the name of {task_name} is not found in this guild" + ) + return + + if isinstance(author_or_channel, discord.Member): + if task.author_id == author_or_channel.id: + await ctx.maybe_send_embed("Already executing as that member") + return + + await task.set_author(author_or_channel) # also saves + elif isinstance(author_or_channel, discord.TextChannel): + if task.channel_id == author_or_channel.id: + await ctx.maybe_send_embed("Already executing in that channel") + return + + await task.set_channel(author_or_channel) + else: + await ctx.maybe_send_embed("Unsupported result") + return + + await ctx.tick() + + @fifo.command(name="resume") + async def fifo_resume(self, ctx: commands.Context, task_name: Optional[str] = None): + """ + Provide a task name to resume execution of a task. + + Otherwise resumes execution of all tasks on all guilds + If the task isn't currently scheduled, will schedule it + """ + if task_name is None: + if self.scheduler.state == STATE_PAUSED: + self.scheduler.resume() + await ctx.maybe_send_embed("All task execution for all guilds has been resumed") + else: + await ctx.maybe_send_embed("Task execution is not paused, can't resume") + else: + task = Task(task_name, ctx.guild.id, self.config, bot=self.bot) + await task.load_from_config() + + if task.data is None: + await ctx.maybe_send_embed( + f"Task by the name of {task_name} is not found in this guild" + ) + return + + if await self._resume_job(task): + await ctx.maybe_send_embed(f"Execution of {task_name=} has been resumed") + else: + await ctx.maybe_send_embed(f"Failed to resume {task_name=}") + + @fifo.command(name="pause") + async def fifo_pause(self, ctx: commands.Context, task_name: Optional[str] = None): + """ + Provide a task name to pause execution of a task + + Otherwise pauses execution of all tasks on all guilds + """ + if task_name is None: + if self.scheduler.state == STATE_RUNNING: + self.scheduler.pause() + await ctx.maybe_send_embed("All task execution for all guilds has been paused") + else: + await ctx.maybe_send_embed("Task execution is not running, can't pause") + else: + task = Task(task_name, ctx.guild.id, self.config, bot=self.bot) + await task.load_from_config() + + if task.data is None: + await ctx.maybe_send_embed( + f"Task by the name of {task_name} is not found in this guild" + ) + return + + if await self._pause_job(task): + await ctx.maybe_send_embed(f"Execution of {task_name=} has been paused") + else: + await ctx.maybe_send_embed(f"Failed to pause {task_name=}") + + @fifo.command(name="details") + async def fifo_details(self, ctx: commands.Context, task_name: str): + """ + Provide all the details on the specified task name + """ + task = Task(task_name, ctx.guild.id, self.config, bot=self.bot) + await task.load_from_config() + + if task.data is None: + await ctx.maybe_send_embed( + f"Task by the name of {task_name} is not found in this guild" + ) + return + + embed = discord.Embed(title=f"Task: {task_name}") + + embed.add_field( + name="Task command", value=f"{ctx.prefix}{task.get_command_str()}", inline=False + ) + + guild: discord.Guild = self.bot.get_guild(task.guild_id) + + if guild is not None: + author: discord.Member = guild.get_member(task.author_id) + channel: discord.TextChannel = guild.get_channel(task.channel_id) + embed.add_field(name="Server", value=guild.name) + if author is not None: + embed.add_field(name="Author", value=author.mention) + if channel is not None: + embed.add_field(name="Channel", value=channel.mention) + + else: + embed.add_field(name="Server", value="Server not found", inline=False) + + trigger_str = "\n".join(str(t) for t in await task.get_triggers()) + if trigger_str: + embed.add_field(name="Triggers", value=trigger_str, inline=False) + + job = await self._get_job(task) + if job and job.next_run_time: + embed.timestamp = job.next_run_time + + await ctx.send(embed=embed) + + @fifo.command(name="list") + async def fifo_list(self, ctx: commands.Context, all_guilds: bool = False): + """ + Lists all current tasks and their triggers. + + Do `[p]fifo list True` to see tasks from all guilds + """ + if all_guilds: + pass + else: + out = "" + all_tasks = await self.config.guild(ctx.guild).tasks() + for task_name, task_data in all_tasks.items(): + out += f"{task_name}: {task_data}\n" + + if out: + if len(out) > 2000: + for page in pagify(out): + await ctx.maybe_send_embed(page) + else: + await ctx.maybe_send_embed(out) + else: + await ctx.maybe_send_embed("No tasks to list") + + @fifo.command(name="add") + async def fifo_add(self, ctx: commands.Context, task_name: str, *, command_to_execute: str): + """ + Add a new task to this guild's task list + """ + if (await self.config.guild(ctx.guild).tasks.get_raw(task_name, default=None)) is not None: + await ctx.maybe_send_embed(f"Task already exists with {task_name=}") + return + + if "_" in task_name: # See _disassemble_job_id + await ctx.maybe_send_embed("Task name cannot contain underscores") + return + + if not await self._check_parsable_command(ctx, command_to_execute): + await ctx.maybe_send_embed( + "Failed to parse command. Make sure not to include the prefix" + ) + return + + task = Task(task_name, ctx.guild.id, self.config, ctx.author.id, ctx.channel.id, self.bot) + await task.set_commmand_str(command_to_execute) + await task.save_all() + await ctx.tick() + + @fifo.command(name="delete") + async def fifo_delete(self, ctx: commands.Context, task_name: str): + """ + Deletes a task from this guild's task list + """ + task = Task(task_name, ctx.guild.id, self.config, bot=self.bot) + await task.load_from_config() + + if task.data is None: + await ctx.maybe_send_embed( + f"Task by the name of {task_name} is not found in this guild" + ) + return + + await self._delete_task(task) + await ctx.maybe_send_embed(f"Task[{task_name}] has been deleted from this guild") + + @fifo.command(name="cleartriggers", aliases=["cleartrigger"]) + async def fifo_cleartriggers(self, ctx: commands.Context, task_name: str): + """ + Removes all triggers from specified task + + Useful to start over with new trigger + """ + + task = Task(task_name, ctx.guild.id, self.config, bot=self.bot) + await task.load_from_config() + + if task.data is None: + await ctx.maybe_send_embed( + f"Task by the name of {task_name} is not found in this guild" + ) + return + + await task.clear_triggers() + await ctx.tick() + + @fifo.group(name="addtrigger", aliases=["trigger"]) + async def fifo_trigger(self, ctx: commands.Context): + """ + Add a new trigger for a task from the current guild. + """ + if ctx.invoked_subcommand is None: + pass + + @fifo_trigger.command(name="interval") + async def fifo_trigger_interval( + self, ctx: commands.Context, task_name: str, *, interval_str: TimedeltaConverter + ): + """ + Add an interval trigger to the specified task + """ + + task = Task(task_name, ctx.guild.id, self.config, bot=self.bot) + await task.load_from_config() + + if task.data is None: + await ctx.maybe_send_embed( + f"Task by the name of {task_name} is not found in this guild" + ) + return + + result = await task.add_trigger("interval", interval_str) + if not result: + await ctx.maybe_send_embed( + "Failed to add an interval trigger to this task, see console for logs" + ) + return + await task.save_data() + job: Job = await self._process_task(task) + delta_from_now: timedelta = job.next_run_time - datetime.now(job.next_run_time.tzinfo) + await ctx.maybe_send_embed( + f"Task `{task_name}` added interval of {interval_str} to its scheduled runtimes\n\n" + f"Next run time: {job.next_run_time} ({delta_from_now.total_seconds()} seconds)" + ) + + @fifo_trigger.command(name="date") + async def fifo_trigger_date( + self, ctx: commands.Context, task_name: str, *, datetime_str: DatetimeConverter + ): + """ + Add a "run once" datetime trigger to the specified task + """ + + task = Task(task_name, ctx.guild.id, self.config) + await task.load_from_config() + + if task.data is None: + await ctx.maybe_send_embed( + f"Task by the name of {task_name} is not found in this guild" + ) + return + + maybe_tz = await self._get_tz(ctx.author) + + result = await task.add_trigger("date", datetime_str, maybe_tz) + if not result: + await ctx.maybe_send_embed( + "Failed to add a date trigger to this task, see console for logs" + ) + return + + await task.save_data() + job: Job = await self._process_task(task) + delta_from_now: timedelta = job.next_run_time - datetime.now(job.next_run_time.tzinfo) + await ctx.maybe_send_embed( + f"Task `{task_name}` added {datetime_str} to its scheduled runtimes\n" + f"Next run time: {job.next_run_time} ({delta_from_now.total_seconds()} seconds)" + ) + + @fifo_trigger.command(name="cron") + async def fifo_trigger_cron( + self, + ctx: commands.Context, + task_name: str, + optional_tz: Optional[TimezoneConverter] = None, + *, + cron_str: CronConverter, + ): + """ + Add a cron "time of day" trigger to the specified task + + See https://crontab.guru/ for help generating the cron_str + """ + task = Task(task_name, ctx.guild.id, self.config) + await task.load_from_config() + + if task.data is None: + await ctx.maybe_send_embed( + f"Task by the name of {task_name} is not found in this guild" + ) + return + + if optional_tz is None: + optional_tz = await self._get_tz(ctx.author) # might still be None + + result = await task.add_trigger("cron", cron_str, optional_tz) + if not result: + await ctx.maybe_send_embed( + "Failed to add a cron trigger to this task, see console for logs" + ) + return + + await task.save_data() + job: Job = await self._process_task(task) + delta_from_now: timedelta = job.next_run_time - datetime.now(job.next_run_time.tzinfo) + await ctx.maybe_send_embed( + f"Task `{task_name}` added cron_str to its scheduled runtimes\n" + f"Next run time: {job.next_run_time} ({delta_from_now.total_seconds()} seconds)" + ) diff --git a/fifo/info.json b/fifo/info.json new file mode 100644 index 0000000..eb2a576 --- /dev/null +++ b/fifo/info.json @@ -0,0 +1,30 @@ +{ + "author": [ + "Bobloy" + ], + "min_bot_version": "3.4.0", + "description": "[BETA] Schedule commands to be run at certain times or intervals", + "hidden": false, + "install_msg": "Thank you for installing FIFO.\nGet started with `[p]load fifo`, then `[p]help FIFO`", + "short": "[BETA] Schedule commands to be run at certain times or intervals", + "end_user_data_statement": "This cog does not store any End User Data", + "requirements": [ + "apscheduler", + "pytz" + ], + "tags": [ + "bobloy", + "utilities", + "tool", + "tools", + "roles", + "schedule", + "cron", + "interval", + "date", + "datetime", + "time", + "calendar", + "timezone" + ] +} \ No newline at end of file diff --git a/fifo/redconfigjobstore.py b/fifo/redconfigjobstore.py new file mode 100644 index 0000000..7e68697 --- /dev/null +++ b/fifo/redconfigjobstore.py @@ -0,0 +1,183 @@ +import asyncio +import base64 +import logging +import pickle +from datetime import datetime +from typing import Tuple, Union + +from apscheduler.job import Job +from apscheduler.jobstores.base import ConflictingIdError, JobLookupError +from apscheduler.jobstores.memory import MemoryJobStore +from apscheduler.schedulers.asyncio import run_in_event_loop +from apscheduler.util import datetime_to_utc_timestamp +from redbot.core import Config + +# TODO: use get_lock on config +from redbot.core.bot import Red +from redbot.core.utils import AsyncIter + +log = logging.getLogger("red.fox_v3.fifo.jobstore") +log.setLevel(logging.DEBUG) + +save_task_objects = [] + + +class RedConfigJobStore(MemoryJobStore): + def __init__(self, config: Config, bot: Red): + super().__init__() + self.config = config + self.bot = bot + self.pickle_protocol = pickle.HIGHEST_PROTOCOL + self._eventloop = self.bot.loop + # TODO: self.config.jobs_index is never used, + # fine but maybe a sign of inefficient use of config + + # task = asyncio.create_task(self.load_from_config()) + # while not task.done(): + # sleep(0.1) + # future = asyncio.ensure_future(self.load_from_config(), loop=self.bot.loop) + + @run_in_event_loop + def start(self, scheduler, alias): + super().start(scheduler, alias) + + async def load_from_config(self, scheduler, alias): + super().start(scheduler, alias) + _jobs = await self.config.jobs() + self._jobs = [ + (await self._decode_job(job), timestamp) async for (job, timestamp) in AsyncIter(_jobs) + ] + # self._jobs_index = await self.config.jobs_index.all() # Overwritten by next + self._jobs_index = {job.id: (job, timestamp) for job, timestamp in self._jobs} + + def _encode_job(self, job: Job): + job_state = job.__getstate__() + new_args = list(job_state["args"]) + new_args[0]["config"] = None + new_args[0]["bot"] = None + job_state["args"] = tuple(new_args) + encoded = base64.b64encode(pickle.dumps(job_state, self.pickle_protocol)) + out = { + "_id": job.id, + "next_run_time": datetime_to_utc_timestamp(job.next_run_time), + "job_state": encoded.decode("ascii"), + } + new_args = list(job_state["args"]) + new_args[0]["config"] = self.config + new_args[0]["bot"] = self.bot + job_state["args"] = tuple(new_args) + # log.debug(f"Encoding job id: {job.id}\n" + # f"Encoded as: {out}") + + return out + + async def _decode_job(self, in_job): + if in_job is None: + return None + job_state = in_job["job_state"] + job_state = pickle.loads(base64.b64decode(job_state)) + new_args = list(job_state["args"]) + new_args[0]["config"] = self.config + new_args[0]["bot"] = self.bot + job_state["args"] = tuple(new_args) + job = Job.__new__(Job) + job.__setstate__(job_state) + job._scheduler = self._scheduler + job._jobstore_alias = self._alias + # task_name, guild_id = _disassemble_job_id(job.id) + # task = Task(task_name, guild_id, self.config) + # await task.load_from_config() + # save_task_objects.append(task) + # + # job.func = task.execute + + # log.debug(f"Decoded job id: {job.id}\n" + # f"Decoded as {job_state}") + + return job + + @run_in_event_loop + def add_job(self, job: Job): + if job.id in self._jobs_index: + raise ConflictingIdError(job.id) + # log.debug(f"Check job args: {job.args=}") + timestamp = datetime_to_utc_timestamp(job.next_run_time) + index = self._get_job_index(timestamp, job.id) # This is fine + self._jobs.insert(index, (job, timestamp)) + self._jobs_index[job.id] = (job, timestamp) + asyncio.create_task(self._async_add_job(job, index, timestamp)) + # log.debug(f"Added job: {self._jobs[index][0].args}") + + async def _async_add_job(self, job, index, timestamp): + encoded_job = self._encode_job(job) + job_tuple = tuple([encoded_job, timestamp]) + async with self.config.jobs() as jobs: + jobs.insert(index, job_tuple) + # await self.config.jobs_index.set_raw(job.id, value=job_tuple) + return True + + @run_in_event_loop + def update_job(self, job): + old_tuple: Tuple[Union[Job, None], Union[datetime, None]] = self._jobs_index.get( + job.id, (None, None) + ) + old_job = old_tuple[0] + old_timestamp = old_tuple[1] + if old_job is None: + raise JobLookupError(job.id) + + # If the next run time has not changed, simply replace the job in its present index. + # Otherwise, reinsert the job to the list to preserve the ordering. + old_index = self._get_job_index(old_timestamp, old_job.id) + new_timestamp = datetime_to_utc_timestamp(job.next_run_time) + asyncio.create_task( + self._async_update_job(job, new_timestamp, old_index, old_job, old_timestamp) + ) + + async def _async_update_job(self, job, new_timestamp, old_index, old_job, old_timestamp): + encoded_job = self._encode_job(job) + if old_timestamp == new_timestamp: + self._jobs[old_index] = (job, new_timestamp) + async with self.config.jobs() as jobs: + jobs[old_index] = (encoded_job, new_timestamp) + else: + del self._jobs[old_index] + new_index = self._get_job_index(new_timestamp, job.id) # This is fine + self._jobs.insert(new_index, (job, new_timestamp)) + async with self.config.jobs() as jobs: + del jobs[old_index] + jobs.insert(new_index, (encoded_job, new_timestamp)) + self._jobs_index[old_job.id] = (job, new_timestamp) + # await self.config.jobs_index.set_raw(old_job.id, value=(encoded_job, new_timestamp)) + + log.debug(f"Async Updated {job.id=}") + log.debug(f"Check job args: {job.args=}") + + @run_in_event_loop + def remove_job(self, job_id): + job, timestamp = self._jobs_index.get(job_id, (None, None)) + if job is None: + raise JobLookupError(job_id) + + index = self._get_job_index(timestamp, job_id) + del self._jobs[index] + del self._jobs_index[job.id] + asyncio.create_task(self._async_remove_job(index, job)) + + async def _async_remove_job(self, index, job): + async with self.config.jobs() as jobs: + del jobs[index] + # await self.config.jobs_index.clear_raw(job.id) + + @run_in_event_loop + def remove_all_jobs(self): + super().remove_all_jobs() + asyncio.create_task(self._async_remove_all_jobs()) + + async def _async_remove_all_jobs(self): + await self.config.jobs.clear() + # await self.config.jobs_index.clear() + + def shutdown(self): + """Removes all jobs without clearing config""" + super().remove_all_jobs() diff --git a/fifo/task.py b/fifo/task.py new file mode 100644 index 0000000..f7dc45a --- /dev/null +++ b/fifo/task.py @@ -0,0 +1,371 @@ +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Union + +import discord +from apscheduler.triggers.base import BaseTrigger +from apscheduler.triggers.combining import OrTrigger +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger +from apscheduler.triggers.interval import IntervalTrigger +from discord.utils import time_snowflake +from pytz import timezone +from redbot.core import Config, commands +from redbot.core.bot import Red + +log = logging.getLogger("red.fox_v3.fifo.task") + + +async def _do_nothing(*args, **kwargs): + pass + + +def get_trigger(data): + if data["type"] == "interval": + parsed_time = data["time_data"] + return IntervalTrigger(days=parsed_time.days, seconds=parsed_time.seconds) + + if data["type"] == "date": + return DateTrigger(data["time_data"], timezone=data["tzinfo"]) + + if data["type"] == "cron": + return CronTrigger.from_crontab(data["time_data"], timezone=data["tzinfo"]) + + return False + + +def parse_triggers(data: Union[Dict, None]): + if data is None or not data.get("triggers", False): # No triggers + return None + + if len(data["triggers"]) > 1: # Multiple triggers + return OrTrigger(get_trigger(t_data) for t_data in data["triggers"]) + + return get_trigger(data["triggers"][0]) + + +class FakeMessage: + def __init__(self, message: discord.Message): + d = {k: getattr(message, k, None) for k in dir(message)} + self.__dict__.update(**d) + + +def neuter_message(message: FakeMessage): + message.delete = _do_nothing + message.edit = _do_nothing + message.publish = _do_nothing + message.pin = _do_nothing + message.unpin = _do_nothing + message.add_reaction = _do_nothing + message.remove_reaction = _do_nothing + message.clear_reaction = _do_nothing + message.clear_reactions = _do_nothing + message.ack = _do_nothing + + return message + + +class Task: + default_task_data = {"triggers": [], "command_str": ""} + + default_trigger = { + "type": "", + "time_data": None, # Used for Interval and Date Triggers + "tzinfo": None, + } + + def __init__( + self, name: str, guild_id, config: Config, author_id=None, channel_id=None, bot: Red = None + ): + self.name = name + self.guild_id = guild_id + self.config = config + self.bot = bot + self.author_id = author_id + self.channel_id = channel_id + self.data = None + + async def _encode_time_triggers(self): + if not self.data or not self.data.get("triggers", None): + return [] + + triggers = [] + for t in self.data["triggers"]: + if t["type"] == "interval": # Convert into timedelta + td: timedelta = t["time_data"] + + triggers.append( + {"type": t["type"], "time_data": {"days": td.days, "seconds": td.seconds}} + ) + continue + + if t["type"] == "date": # Convert into datetime + dt: datetime = t["time_data"] + triggers.append( + { + "type": t["type"], + "time_data": dt.isoformat(), + "tzinfo": getattr(t["tzinfo"], "zone", None), + } + ) + # triggers.append( + # { + # "type": t["type"], + # "time_data": { + # "year": dt.year, + # "month": dt.month, + # "day": dt.day, + # "hour": dt.hour, + # "minute": dt.minute, + # "second": dt.second, + # "tzinfo": dt.tzinfo, + # }, + # } + # ) + continue + + if t["type"] == "cron": + if t["tzinfo"] is None: + triggers.append(t) # already a string, nothing to do + else: + triggers.append( + { + "type": t["type"], + "time_data": t["time_data"], + "tzinfo": getattr(t["tzinfo"], "zone", None), + } + ) + continue + + raise NotImplemented + + return triggers + + async def _decode_time_triggers(self): + if not self.data or not self.data.get("triggers", None): + return + + for t in self.data["triggers"]: + # Backwards compatibility + if "tzinfo" not in t: + t["tzinfo"] = None + + # First decode timezone if there is one + if t["tzinfo"] is not None: + t["tzinfo"] = timezone(t["tzinfo"]) + + if t["type"] == "interval": # Convert into timedelta + t["time_data"] = timedelta(**t["time_data"]) + continue + + if t["type"] == "date": # Convert into datetime + # self.data["triggers"][n]["time_data"] = datetime(**t["time_data"]) + t["time_data"] = datetime.fromisoformat(t["time_data"]) + continue + + if t["type"] == "cron": + continue # already a string + + raise NotImplemented + + # async def load_from_data(self, data: Dict): + # self.data = data.copy() + + async def load_from_config(self): + data = await self.config.guild_from_id(self.guild_id).tasks.get_raw( + self.name, default=None + ) + + if not data: + return + + self.author_id = data["author_id"] + self.guild_id = data["guild_id"] + self.channel_id = data["channel_id"] + + self.data = data["data"] + + await self._decode_time_triggers() + return self.data + + async def get_triggers(self) -> List[Union[IntervalTrigger, DateTrigger]]: + if not self.data: + await self.load_from_config() + + if self.data is None or "triggers" not in self.data: # No triggers + return [] + + return [get_trigger(t) for t in self.data["triggers"]] + + async def get_combined_trigger(self) -> Union[BaseTrigger, None]: + if not self.data: + await self.load_from_config() + + return parse_triggers(self.data) + + # async def set_job_id(self, job_id): + # if self.data is None: + # await self.load_from_config() + # + # self.data["job_id"] = job_id + + async def save_all(self): + """To be used when creating an new task""" + + data_to_save = self.default_task_data.copy() + if self.data: + data_to_save["command_str"] = self.get_command_str() + data_to_save["triggers"] = await self._encode_time_triggers() + + to_save = { + "guild_id": self.guild_id, + "author_id": self.author_id, + "channel_id": self.channel_id, + "data": data_to_save, + } + await self.config.guild_from_id(self.guild_id).tasks.set_raw(self.name, value=to_save) + + async def save_data(self): + """To be used when updating triggers""" + if not self.data: + return + + data_to_save = self.data.copy() + data_to_save["triggers"] = await self._encode_time_triggers() + + await self.config.guild_from_id(self.guild_id).tasks.set_raw( + self.name, "data", value=data_to_save + ) + + async def execute(self): + if not self.data or not self.get_command_str(): + log.warning(f"Could not execute task due to data problem: {self.data=}") + return False + + guild: discord.Guild = self.bot.get_guild(self.guild_id) # used for get_prefix + if guild is None: + log.warning(f"Could not execute task due to missing guild: {self.guild_id}") + return False + channel: discord.TextChannel = guild.get_channel(self.channel_id) + if channel is None: + log.warning(f"Could not execute task due to missing channel: {self.channel_id}") + return False + author: discord.User = guild.get_member(self.author_id) + if author is None: + log.warning(f"Could not execute task due to missing author: {self.author_id}") + return False + + actual_message: discord.Message = channel.last_message + # I'd like to present you my chain of increasingly desperate message fetching attempts + if actual_message is None: + # log.warning("No message found in channel cache yet, skipping execution") + # return + actual_message = await channel.fetch_message(channel.last_message_id) + if actual_message is None: # last_message_id was an invalid message I guess + actual_message = await channel.history(limit=1).flatten() + if not actual_message: # Basically only happens if the channel has no messages + actual_message = await author.history(limit=1).flatten() + if not actual_message: # Okay, the *author* has never sent a message? + log.warning("No message found in channel cache yet, skipping execution") + return + actual_message = actual_message[0] + + message = FakeMessage(actual_message) + # message = FakeMessage2 + message.author = author + message.guild = guild # Just in case we got desperate + message.channel = channel + message.id = time_snowflake(datetime.now()) # Pretend to be now + message = neuter_message(message) + + # absolutely weird that this takes a message object instead of guild + prefixes = await self.bot.get_prefix(message) + if isinstance(prefixes, str): + prefix = prefixes + else: + prefix = prefixes[0] + + message.content = f"{prefix}{self.get_command_str()}" + + if not message.guild or not message.author or not message.content: + log.warning(f"Could not execute task due to message problem: {message}") + return False + + new_ctx: commands.Context = await self.bot.get_context(message) + new_ctx.assume_yes = True + if not new_ctx.valid: + log.warning( + f"Could not execute Task[{self.name}] due invalid context: {new_ctx.invoked_with}" + ) + return False + + await self.bot.invoke(new_ctx) + return True + + async def set_bot(self, bot: Red): + self.bot = bot + + async def set_author(self, author: Union[discord.User, discord.Member, str]): + self.author_id = getattr(author, "id", None) or author + await self.config.guild_from_id(self.guild_id).tasks.set_raw( + self.name, "author_id", value=self.author_id + ) + + async def set_channel(self, channel: Union[discord.TextChannel, str]): + self.channel_id = getattr(channel, "id", None) or channel + await self.config.guild_from_id(self.guild_id).tasks.set_raw( + self.name, "channel_id", value=self.channel_id + ) + + def get_command_str(self): + return self.data.get("command_str", "") + + async def set_commmand_str(self, command_str): + if not self.data: + self.data = self.default_task_data.copy() + self.data["command_str"] = command_str + return True + + async def add_trigger( + self, param, parsed_time: Union[timedelta, datetime, str], timezone=None + ): + # TODO: Save timezone separately for cron and date triggers + trigger_data = self.default_trigger.copy() + trigger_data["type"] = param + trigger_data["time_data"] = parsed_time + if timezone is not None: + trigger_data["tzinfo"] = timezone + + if not get_trigger(trigger_data): + return False + + if not self.data: + self.data = self.default_task_data.copy() + + self.data["triggers"].append(trigger_data) + return True + + def __setstate__(self, task_state): + self.name = task_state["name"] + self.guild_id = task_state["guild_id"] + self.config = task_state["config"] + self.bot = None + self.author_id = None + self.channel_id = None + self.data = None + + def __getstate__(self): + return { + "name": self.name, + "guild_id": self.guild_id, + "config": self.config, + "bot": self.bot, + } + + async def clear_triggers(self): + self.data["triggers"] = [] + await self.save_data() + + async def delete_self(self): + """Hopefully nothing uses the object after running this...""" + await self.config.guild_from_id(self.guild_id).tasks.clear_raw(self.name) diff --git a/fifo/timezones.py b/fifo/timezones.py new file mode 100644 index 0000000..54d7c3e --- /dev/null +++ b/fifo/timezones.py @@ -0,0 +1,230 @@ +""" +Timezone information for the dateutil parser + +All credit to https://github.com/prefrontal/dateutil-parser-timezones +""" + +# from dateutil.tz import gettz +from pytz import timezone + + +def assemble_timezones(): + """ + Assembles a dictionary of timezone abbreviations and values + :return: Dictionary of abbreviation keys and timezone values + """ + timezones = {} + + timezones["ACDT"] = timezone( + "Australia/Darwin" + ) # Australian Central Daylight Savings Time (UTC+10:30) + timezones["ACST"] = timezone( + "Australia/Darwin" + ) # Australian Central Standard Time (UTC+09:30) + timezones["ACT"] = timezone("Brazil/Acre") # Acre Time (UTC−05) + timezones["ADT"] = timezone("America/Halifax") # Atlantic Daylight Time (UTC−03) + timezones["AEDT"] = timezone( + "Australia/Sydney" + ) # Australian Eastern Daylight Savings Time (UTC+11) + timezones["AEST"] = timezone("Australia/Sydney") # Australian Eastern Standard Time (UTC+10) + timezones["AFT"] = timezone("Asia/Kabul") # Afghanistan Time (UTC+04:30) + timezones["AKDT"] = timezone("America/Juneau") # Alaska Daylight Time (UTC−08) + timezones["AKST"] = timezone("America/Juneau") # Alaska Standard Time (UTC−09) + timezones["AMST"] = timezone("America/Manaus") # Amazon Summer Time (Brazil)[1] (UTC−03) + timezones["AMT"] = timezone("America/Manaus") # Amazon Time (Brazil)[2] (UTC−04) + timezones["ART"] = timezone("America/Cordoba") # Argentina Time (UTC−03) + timezones["AST"] = timezone("Asia/Riyadh") # Arabia Standard Time (UTC+03) + timezones["AWST"] = timezone("Australia/Perth") # Australian Western Standard Time (UTC+08) + timezones["AZOST"] = timezone("Atlantic/Azores") # Azores Summer Time (UTC±00) + timezones["AZOT"] = timezone("Atlantic/Azores") # Azores Standard Time (UTC−01) + timezones["AZT"] = timezone("Asia/Baku") # Azerbaijan Time (UTC+04) + timezones["BDT"] = timezone("Asia/Brunei") # Brunei Time (UTC+08) + timezones["BIOT"] = timezone("Etc/GMT+6") # British Indian Ocean Time (UTC+06) + timezones["BIT"] = timezone("Pacific/Funafuti") # Baker Island Time (UTC−12) + timezones["BOT"] = timezone("America/La_Paz") # Bolivia Time (UTC−04) + timezones["BRST"] = timezone("America/Sao_Paulo") # Brasilia Summer Time (UTC−02) + timezones["BRT"] = timezone("America/Sao_Paulo") # Brasilia Time (UTC−03) + timezones["BST"] = timezone("Asia/Dhaka") # Bangladesh Standard Time (UTC+06) + timezones["BTT"] = timezone("Asia/Thimphu") # Bhutan Time (UTC+06) + timezones["CAT"] = timezone("Africa/Harare") # Central Africa Time (UTC+02) + timezones["CCT"] = timezone("Indian/Cocos") # Cocos Islands Time (UTC+06:30) + timezones["CDT"] = timezone( + "America/Chicago" + ) # Central Daylight Time (North America) (UTC−05) + timezones["CEST"] = timezone( + "Europe/Berlin" + ) # Central European Summer Time (Cf. HAEC) (UTC+02) + timezones["CET"] = timezone("Europe/Berlin") # Central European Time (UTC+01) + timezones["CHADT"] = timezone("Pacific/Chatham") # Chatham Daylight Time (UTC+13:45) + timezones["CHAST"] = timezone("Pacific/Chatham") # Chatham Standard Time (UTC+12:45) + timezones["CHOST"] = timezone("Asia/Choibalsan") # Choibalsan Summer Time (UTC+09) + timezones["CHOT"] = timezone("Asia/Choibalsan") # Choibalsan Standard Time (UTC+08) + timezones["CHST"] = timezone("Pacific/Guam") # Chamorro Standard Time (UTC+10) + timezones["CHUT"] = timezone("Pacific/Chuuk") # Chuuk Time (UTC+10) + timezones["CIST"] = timezone("Etc/GMT-8") # Clipperton Island Standard Time (UTC−08) + timezones["CIT"] = timezone("Asia/Makassar") # Central Indonesia Time (UTC+08) + timezones["CKT"] = timezone("Pacific/Rarotonga") # Cook Island Time (UTC−10) + timezones["CLST"] = timezone("America/Santiago") # Chile Summer Time (UTC−03) + timezones["CLT"] = timezone("America/Santiago") # Chile Standard Time (UTC−04) + timezones["COST"] = timezone("America/Bogota") # Colombia Summer Time (UTC−04) + timezones["COT"] = timezone("America/Bogota") # Colombia Time (UTC−05) + timezones["CST"] = timezone( + "America/Chicago" + ) # Central Standard Time (North America) (UTC−06) + timezones["CT"] = timezone("Asia/Chongqing") # China time (UTC+08) + timezones["CVT"] = timezone("Atlantic/Cape_Verde") # Cape Verde Time (UTC−01) + timezones["CXT"] = timezone("Indian/Christmas") # Christmas Island Time (UTC+07) + timezones["DAVT"] = timezone("Antarctica/Davis") # Davis Time (UTC+07) + timezones["DDUT"] = timezone("Antarctica/DumontDUrville") # Dumont d'Urville Time (UTC+10) + timezones["DFT"] = timezone( + "Europe/Berlin" + ) # AIX equivalent of Central European Time (UTC+01) + timezones["EASST"] = timezone("Chile/EasterIsland") # Easter Island Summer Time (UTC−05) + timezones["EAST"] = timezone("Chile/EasterIsland") # Easter Island Standard Time (UTC−06) + timezones["EAT"] = timezone("Africa/Mogadishu") # East Africa Time (UTC+03) + timezones["ECT"] = timezone("America/Guayaquil") # Ecuador Time (UTC−05) + timezones["EDT"] = timezone( + "America/New_York" + ) # Eastern Daylight Time (North America) (UTC−04) + timezones["EEST"] = timezone("Europe/Bucharest") # Eastern European Summer Time (UTC+03) + timezones["EET"] = timezone("Europe/Bucharest") # Eastern European Time (UTC+02) + timezones["EGST"] = timezone("America/Scoresbysund") # Eastern Greenland Summer Time (UTC±00) + timezones["EGT"] = timezone("America/Scoresbysund") # Eastern Greenland Time (UTC−01) + timezones["EIT"] = timezone("Asia/Jayapura") # Eastern Indonesian Time (UTC+09) + timezones["EST"] = timezone( + "America/New_York" + ) # Eastern Standard Time (North America) (UTC−05) + timezones["FET"] = timezone("Europe/Minsk") # Further-eastern European Time (UTC+03) + timezones["FJT"] = timezone("Pacific/Fiji") # Fiji Time (UTC+12) + timezones["FKST"] = timezone("Atlantic/Stanley") # Falkland Islands Summer Time (UTC−03) + timezones["FKT"] = timezone("Atlantic/Stanley") # Falkland Islands Time (UTC−04) + timezones["FNT"] = timezone("Brazil/DeNoronha") # Fernando de Noronha Time (UTC−02) + timezones["GALT"] = timezone("Pacific/Galapagos") # Galapagos Time (UTC−06) + timezones["GAMT"] = timezone("Pacific/Gambier") # Gambier Islands (UTC−09) + timezones["GET"] = timezone("Asia/Tbilisi") # Georgia Standard Time (UTC+04) + timezones["GFT"] = timezone("America/Cayenne") # French Guiana Time (UTC−03) + timezones["GILT"] = timezone("Pacific/Tarawa") # Gilbert Island Time (UTC+12) + timezones["GIT"] = timezone("Pacific/Gambier") # Gambier Island Time (UTC−09) + timezones["GMT"] = timezone("GMT") # Greenwich Mean Time (UTC±00) + timezones["GST"] = timezone("Asia/Muscat") # Gulf Standard Time (UTC+04) + timezones["GYT"] = timezone("America/Guyana") # Guyana Time (UTC−04) + timezones["HADT"] = timezone("Pacific/Honolulu") # Hawaii-Aleutian Daylight Time (UTC−09) + timezones["HAEC"] = timezone("Europe/Paris") # Heure Avancée d'Europe Centrale (CEST) (UTC+02) + timezones["HAST"] = timezone("Pacific/Honolulu") # Hawaii-Aleutian Standard Time (UTC−10) + timezones["HKT"] = timezone("Asia/Hong_Kong") # Hong Kong Time (UTC+08) + timezones["HMT"] = timezone("Indian/Kerguelen") # Heard and McDonald Islands Time (UTC+05) + timezones["HOVST"] = timezone("Asia/Hovd") # Khovd Summer Time (UTC+08) + timezones["HOVT"] = timezone("Asia/Hovd") # Khovd Standard Time (UTC+07) + timezones["ICT"] = timezone("Asia/Ho_Chi_Minh") # Indochina Time (UTC+07) + timezones["IDT"] = timezone("Asia/Jerusalem") # Israel Daylight Time (UTC+03) + timezones["IOT"] = timezone("Etc/GMT+3") # Indian Ocean Time (UTC+03) + timezones["IRDT"] = timezone("Asia/Tehran") # Iran Daylight Time (UTC+04:30) + timezones["IRKT"] = timezone("Asia/Irkutsk") # Irkutsk Time (UTC+08) + timezones["IRST"] = timezone("Asia/Tehran") # Iran Standard Time (UTC+03:30) + timezones["IST"] = timezone("Asia/Kolkata") # Indian Standard Time (UTC+05:30) + timezones["JST"] = timezone("Asia/Tokyo") # Japan Standard Time (UTC+09) + timezones["KGT"] = timezone("Asia/Bishkek") # Kyrgyzstan time (UTC+06) + timezones["KOST"] = timezone("Pacific/Kosrae") # Kosrae Time (UTC+11) + timezones["KRAT"] = timezone("Asia/Krasnoyarsk") # Krasnoyarsk Time (UTC+07) + timezones["KST"] = timezone("Asia/Seoul") # Korea Standard Time (UTC+09) + timezones["LHST"] = timezone("Australia/Lord_Howe") # Lord Howe Standard Time (UTC+10:30) + timezones["LINT"] = timezone("Pacific/Kiritimati") # Line Islands Time (UTC+14) + timezones["MAGT"] = timezone("Asia/Magadan") # Magadan Time (UTC+12) + timezones["MART"] = timezone("Pacific/Marquesas") # Marquesas Islands Time (UTC−09:30) + timezones["MAWT"] = timezone("Antarctica/Mawson") # Mawson Station Time (UTC+05) + timezones["MDT"] = timezone( + "America/Denver" + ) # Mountain Daylight Time (North America) (UTC−06) + timezones["MEST"] = timezone( + "Europe/Paris" + ) # Middle European Summer Time Same zone as CEST (UTC+02) + timezones["MET"] = timezone("Europe/Berlin") # Middle European Time Same zone as CET (UTC+01) + timezones["MHT"] = timezone("Pacific/Kwajalein") # Marshall Islands (UTC+12) + timezones["MIST"] = timezone("Antarctica/Macquarie") # Macquarie Island Station Time (UTC+11) + timezones["MIT"] = timezone("Pacific/Marquesas") # Marquesas Islands Time (UTC−09:30) + timezones["MMT"] = timezone("Asia/Rangoon") # Myanmar Standard Time (UTC+06:30) + timezones["MSK"] = timezone("Europe/Moscow") # Moscow Time (UTC+03) + timezones["MST"] = timezone( + "America/Denver" + ) # Mountain Standard Time (North America) (UTC−07) + timezones["MUT"] = timezone("Indian/Mauritius") # Mauritius Time (UTC+04) + timezones["MVT"] = timezone("Indian/Maldives") # Maldives Time (UTC+05) + timezones["MYT"] = timezone("Asia/Kuching") # Malaysia Time (UTC+08) + timezones["NCT"] = timezone("Pacific/Noumea") # New Caledonia Time (UTC+11) + timezones["NDT"] = timezone("Canada/Newfoundland") # Newfoundland Daylight Time (UTC−02:30) + timezones["NFT"] = timezone("Pacific/Norfolk") # Norfolk Time (UTC+11) + timezones["NPT"] = timezone("Asia/Kathmandu") # Nepal Time (UTC+05:45) + timezones["NST"] = timezone("Canada/Newfoundland") # Newfoundland Standard Time (UTC−03:30) + timezones["NT"] = timezone("Canada/Newfoundland") # Newfoundland Time (UTC−03:30) + timezones["NUT"] = timezone("Pacific/Niue") # Niue Time (UTC−11) + timezones["NZDT"] = timezone("Pacific/Auckland") # New Zealand Daylight Time (UTC+13) + timezones["NZST"] = timezone("Pacific/Auckland") # New Zealand Standard Time (UTC+12) + timezones["OMST"] = timezone("Asia/Omsk") # Omsk Time (UTC+06) + timezones["ORAT"] = timezone("Asia/Oral") # Oral Time (UTC+05) + timezones["PDT"] = timezone( + "America/Los_Angeles" + ) # Pacific Daylight Time (North America) (UTC−07) + timezones["PET"] = timezone("America/Lima") # Peru Time (UTC−05) + timezones["PETT"] = timezone("Asia/Kamchatka") # Kamchatka Time (UTC+12) + timezones["PGT"] = timezone("Pacific/Port_Moresby") # Papua New Guinea Time (UTC+10) + timezones["PHOT"] = timezone("Pacific/Enderbury") # Phoenix Island Time (UTC+13) + timezones["PKT"] = timezone("Asia/Karachi") # Pakistan Standard Time (UTC+05) + timezones["PMDT"] = timezone( + "America/Miquelon" + ) # Saint Pierre and Miquelon Daylight time (UTC−02) + timezones["PMST"] = timezone( + "America/Miquelon" + ) # Saint Pierre and Miquelon Standard Time (UTC−03) + timezones["PONT"] = timezone("Pacific/Pohnpei") # Pohnpei Standard Time (UTC+11) + timezones["PST"] = timezone( + "America/Los_Angeles" + ) # Pacific Standard Time (North America) (UTC−08) + timezones["PYST"] = timezone( + "America/Asuncion" + ) # Paraguay Summer Time (South America)[7] (UTC−03) + timezones["PYT"] = timezone("America/Asuncion") # Paraguay Time (South America)[8] (UTC−04) + timezones["RET"] = timezone("Indian/Reunion") # Réunion Time (UTC+04) + timezones["ROTT"] = timezone("Antarctica/Rothera") # Rothera Research Station Time (UTC−03) + timezones["SAKT"] = timezone("Asia/Vladivostok") # Sakhalin Island time (UTC+11) + timezones["SAMT"] = timezone("Europe/Samara") # Samara Time (UTC+04) + timezones["SAST"] = timezone("Africa/Johannesburg") # South African Standard Time (UTC+02) + timezones["SBT"] = timezone("Pacific/Guadalcanal") # Solomon Islands Time (UTC+11) + timezones["SCT"] = timezone("Indian/Mahe") # Seychelles Time (UTC+04) + timezones["SGT"] = timezone("Asia/Singapore") # Singapore Time (UTC+08) + timezones["SLST"] = timezone("Asia/Colombo") # Sri Lanka Standard Time (UTC+05:30) + timezones["SRET"] = timezone("Asia/Srednekolymsk") # Srednekolymsk Time (UTC+11) + timezones["SRT"] = timezone("America/Paramaribo") # Suriname Time (UTC−03) + timezones["SST"] = timezone("Asia/Singapore") # Singapore Standard Time (UTC+08) + timezones["SYOT"] = timezone("Antarctica/Syowa") # Showa Station Time (UTC+03) + timezones["TAHT"] = timezone("Pacific/Tahiti") # Tahiti Time (UTC−10) + timezones["TFT"] = timezone("Indian/Kerguelen") # Indian/Kerguelen (UTC+05) + timezones["THA"] = timezone("Asia/Bangkok") # Thailand Standard Time (UTC+07) + timezones["TJT"] = timezone("Asia/Dushanbe") # Tajikistan Time (UTC+05) + timezones["TKT"] = timezone("Pacific/Fakaofo") # Tokelau Time (UTC+13) + timezones["TLT"] = timezone("Asia/Dili") # Timor Leste Time (UTC+09) + timezones["TMT"] = timezone("Asia/Ashgabat") # Turkmenistan Time (UTC+05) + timezones["TOT"] = timezone("Pacific/Tongatapu") # Tonga Time (UTC+13) + timezones["TVT"] = timezone("Pacific/Funafuti") # Tuvalu Time (UTC+12) + timezones["ULAST"] = timezone("Asia/Ulan_Bator") # Ulaanbaatar Summer Time (UTC+09) + timezones["ULAT"] = timezone("Asia/Ulan_Bator") # Ulaanbaatar Standard Time (UTC+08) + timezones["USZ1"] = timezone("Europe/Kaliningrad") # Kaliningrad Time (UTC+02) + timezones["UTC"] = timezone("UTC") # Coordinated Universal Time (UTC±00) + timezones["UYST"] = timezone("America/Montevideo") # Uruguay Summer Time (UTC−02) + timezones["UYT"] = timezone("America/Montevideo") # Uruguay Standard Time (UTC−03) + timezones["UZT"] = timezone("Asia/Tashkent") # Uzbekistan Time (UTC+05) + timezones["VET"] = timezone("America/Caracas") # Venezuelan Standard Time (UTC−04) + timezones["VLAT"] = timezone("Asia/Vladivostok") # Vladivostok Time (UTC+10) + timezones["VOLT"] = timezone("Europe/Volgograd") # Volgograd Time (UTC+04) + timezones["VOST"] = timezone("Antarctica/Vostok") # Vostok Station Time (UTC+06) + timezones["VUT"] = timezone("Pacific/Efate") # Vanuatu Time (UTC+11) + timezones["WAKT"] = timezone("Pacific/Wake") # Wake Island Time (UTC+12) + timezones["WAST"] = timezone("Africa/Lagos") # West Africa Summer Time (UTC+02) + timezones["WAT"] = timezone("Africa/Lagos") # West Africa Time (UTC+01) + timezones["WEST"] = timezone("Europe/London") # Western European Summer Time (UTC+01) + timezones["WET"] = timezone("Europe/London") # Western European Time (UTC±00) + timezones["WIT"] = timezone("Asia/Jakarta") # Western Indonesian Time (UTC+07) + timezones["WST"] = timezone("Australia/Perth") # Western Standard Time (UTC+08) + timezones["YAKT"] = timezone("Asia/Yakutsk") # Yakutsk Time (UTC+09) + timezones["YEKT"] = timezone("Asia/Yekaterinburg") # Yekaterinburg Time (UTC+05) + + return timezones diff --git a/firstmessage/__init__.py b/firstmessage/__init__.py new file mode 100644 index 0000000..4dab2ed --- /dev/null +++ b/firstmessage/__init__.py @@ -0,0 +1,5 @@ +from .firstmessage import FirstMessage + + +async def setup(bot): + bot.add_cog(FirstMessage(bot)) diff --git a/firstmessage/firstmessage.py b/firstmessage/firstmessage.py new file mode 100644 index 0000000..f13bd60 --- /dev/null +++ b/firstmessage/firstmessage.py @@ -0,0 +1,49 @@ +import logging + +import discord +from redbot.core import Config, commands +from redbot.core.bot import Red + +log = logging.getLogger("red.fox_v3.firstmessage") + + +class FirstMessage(commands.Cog): + """ + Provides a link to the first message in the provided channel + """ + + def __init__(self, bot: Red): + super().__init__() + self.bot = bot + self.config = Config.get_conf( + self, identifier=701051141151167710111511597103101, force_registration=True + ) + + default_guild = {} + + self.config.register_guild(**default_guild) + + async def red_delete_data_for_user(self, **kwargs): + """Nothing to delete""" + return + + @commands.command() + async def firstmessage(self, ctx: commands.Context, channel: discord.TextChannel = None): + """ + Provide a link to the first message in current or provided channel. + """ + if channel is None: + channel = ctx.channel + try: + message: discord.Message = ( + await channel.history(limit=1, oldest_first=True).flatten() + )[0] + except (discord.Forbidden, discord.HTTPException): + log.exception(f"Unable to read message history for {channel.id=}") + await ctx.maybe_send_embed("Unable to read message history for that channel") + return + + em = discord.Embed(description=f"[First Message in {channel.mention}]({message.jump_url})") + em.set_author(name=message.author.display_name, icon_url=message.author.avatar_url) + + await ctx.send(embed=em) diff --git a/firstmessage/info.json b/firstmessage/info.json new file mode 100644 index 0000000..f656d32 --- /dev/null +++ b/firstmessage/info.json @@ -0,0 +1,17 @@ +{ + "author": [ + "Bobloy" + ], + "min_bot_version": "3.4.0", + "description": "Simple cog to jump to the first message of a channel easily", + "hidden": false, + "install_msg": "Thank you for installing FirstMessage.\nGet started with `[p]load firstmessage`, then `[p]help FirstMessage`", + "short": "Simple cog to jump to first message of a channel", + "end_user_data_statement": "This cog does not store any End User Data", + "tags": [ + "bobloy", + "utilities", + "tool", + "tools" + ] +} \ No newline at end of file diff --git a/flag/flag.py b/flag/flag.py index 035ae1d..6216f65 100644 --- a/flag/flag.py +++ b/flag/flag.py @@ -54,7 +54,7 @@ class Flag(Cog): async def flagset(self, ctx: commands.Context): """ My custom cog - + Extra information goes here """ if ctx.invoked_subcommand is None: diff --git a/forcemention/forcemention.py b/forcemention/forcemention.py index dd8948c..3df68e5 100644 --- a/forcemention/forcemention.py +++ b/forcemention/forcemention.py @@ -30,8 +30,8 @@ class ForceMention(Cog): @commands.command() async def forcemention(self, ctx: commands.Context, role: str, *, message=""): """ - Mentions that role, regardless if it's unmentionable - """ + Mentions that role, regardless if it's unmentionable + """ role_obj = get(ctx.guild.roles, name=role) if role_obj is None: await ctx.maybe_send_embed("Couldn't find role named {}".format(role)) diff --git a/hangman/__init__.py b/hangman/__init__.py index dbc62e7..35012c4 100644 --- a/hangman/__init__.py +++ b/hangman/__init__.py @@ -6,4 +6,3 @@ def setup(bot): n = Hangman(bot) data_manager.bundled_data_path(n) bot.add_cog(n) - bot.add_listener(n.on_react, "on_reaction_add") diff --git a/hangman/hangman.py b/hangman/hangman.py index c7d005d..2b6ab07 100644 --- a/hangman/hangman.py +++ b/hangman/hangman.py @@ -50,27 +50,27 @@ class Hangman(Cog): theface = await self.config.guild(guild).theface() self.hanglist[guild] = ( """> - \_________ + \\_________ |/ | | | | | - |\___ + |\\___ """, """> - \_________ + \\_________ |/ | | | | | | - |\___ + |\\___ H""", """> - \_________ + \\_________ |/ | | """ + theface @@ -79,10 +79,10 @@ class Hangman(Cog): | | | - |\___ + |\\___ HA""", """> - \________ + \\________ |/ | | """ + theface @@ -91,10 +91,10 @@ class Hangman(Cog): | | | | - |\___ + |\\___ HAN""", """> - \_________ + \\_________ |/ | | """ + theface @@ -103,43 +103,43 @@ class Hangman(Cog): | | | | - |\___ + |\\___ HANG""", """> - \_________ + \\_________ |/ | | """ + theface + """ - | /|\ + | /|\\ | | | | - |\___ + |\\___ HANGM""", """> - \________ + \\________ |/ | | """ + theface + """ - | /|\ + | /|\\ | | | / | - |\___ + |\\___ HANGMA""", """> - \________ + \\________ |/ | | """ + theface + """ - | /|\ + | /|\\ | | - | / \ + | / \\ | - |\___ + |\\___ HANGMAN""", ) @@ -255,7 +255,7 @@ class Hangman(Cog): elif i in self.the_data[guild]["guesses"] or i not in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": out_str += "__" + i + "__ " else: - out_str += "**\_** " + out_str += "**\\_** " self.winbool[guild] = False return out_str @@ -286,10 +286,10 @@ class Hangman(Cog): await self._reprintgame(message) - @commands.Cog.listener() + @commands.Cog.listener("on_reaction_add") async def on_react(self, reaction, user: Union[discord.User, discord.Member]): - """ Thanks to flapjack reactpoll for guidelines - https://github.com/flapjax/FlapJack-Cogs/blob/master/reactpoll/reactpoll.py""" + """Thanks to flapjack reactpoll for guidelines + https://github.com/flapjax/FlapJack-Cogs/blob/master/reactpoll/reactpoll.py""" guild: discord.Guild = getattr(user, "guild", None) if guild is None: return diff --git a/infochannel/infochannel.py b/infochannel/infochannel.py index c7297b8..b8d36a3 100644 --- a/infochannel/infochannel.py +++ b/infochannel/infochannel.py @@ -1,4 +1,5 @@ import asyncio +from typing import Union import discord from redbot.core import Config, checks, commands @@ -61,10 +62,9 @@ class InfoChannel(Cog): guild: discord.Guild = ctx.guild channel_id = await self.config.guild(guild).channel_id() + channel = None if channel_id is not None: - channel: discord.VoiceChannel = guild.get_channel(channel_id) - else: - channel: discord.VoiceChannel = None + channel: Union[discord.VoiceChannel, None] = guild.get_channel(channel_id) if channel_id is not None and channel is None: await ctx.send("Info channel has been deleted, recreate it?") diff --git a/isitdown/__init__.py b/isitdown/__init__.py new file mode 100644 index 0000000..fdebc2a --- /dev/null +++ b/isitdown/__init__.py @@ -0,0 +1,5 @@ +from .isitdown import IsItDown + + +async def setup(bot): + bot.add_cog(IsItDown(bot)) diff --git a/isitdown/info.json b/isitdown/info.json new file mode 100644 index 0000000..d321732 --- /dev/null +++ b/isitdown/info.json @@ -0,0 +1,17 @@ +{ + "author": [ + "Bobloy" + ], + "min_bot_version": "3.4.0", + "description": "Check if a website/url is down using the https://isitdown.site/ api", + "hidden": false, + "install_msg": "Thank you for installing IsItDown.\nGet started with `[p]load isitdown`, then `[p]help IsItDown`", + "short": "Check if a website/url is down", + "end_user_data_statement": "This cog does not store any End User Data", + "tags": [ + "bobloy", + "utilities", + "tool", + "tools" + ] +} \ No newline at end of file diff --git a/isitdown/isitdown.py b/isitdown/isitdown.py new file mode 100644 index 0000000..f786928 --- /dev/null +++ b/isitdown/isitdown.py @@ -0,0 +1,58 @@ +import logging +import re + +import aiohttp +from redbot.core import Config, commands +from redbot.core.bot import Red + +log = logging.getLogger("red.fox_v3.isitdown") + + +class IsItDown(commands.Cog): + """ + Cog Description + + Less important information about the cog + """ + + def __init__(self, bot: Red): + super().__init__() + self.bot = bot + self.config = Config.get_conf(self, identifier=0, force_registration=True) + + default_guild = {"iids": []} # List of tuple pairs (channel_id, website) + + self.config.register_guild(**default_guild) + + async def red_delete_data_for_user(self, **kwargs): + """Nothing to delete""" + return + + @commands.command(alias=["iid"]) + async def isitdown(self, ctx: commands.Context, url_to_check): + """ + Check if the provided url is down + + Alias: iid + """ + try: + resp = await self._check_if_down(url_to_check) + except AssertionError: + await ctx.maybe_send_embed("Invalid URL provided. Make sure not to include `http://`") + return + + if resp["isitdown"]: + await ctx.maybe_send_embed(f"{url_to_check} is DOWN!") + else: + await ctx.maybe_send_embed(f"{url_to_check} is UP!") + + async def _check_if_down(self, url_to_check): + url = re.compile(r"https?://(www\.)?") + url.sub("", url_to_check).strip().strip("/") + + url = f"https://isitdown.site/api/v3/{url}" + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + assert response.status == 200 + resp = await response.json() + return resp diff --git a/launchlib/info.json b/launchlib/info.json index 8df1059..c1c7ad7 100644 --- a/launchlib/info.json +++ b/launchlib/info.json @@ -8,7 +8,7 @@ "install_msg": "Thank you for installing LaunchLib. Get started with `[p]load launchlib`, then `[p]help LaunchLib`", "short": "Access launch data for space flights", "end_user_data_statement": "This cog does not store any End User Data", - "requirements": ["python-launch-library"], + "requirements": ["python-launch-library>=1.0.6"], "tags": [ "bobloy", "utils", diff --git a/launchlib/launchlib.py b/launchlib/launchlib.py index 0c6eeef..ae870fd 100644 --- a/launchlib/launchlib.py +++ b/launchlib/launchlib.py @@ -22,7 +22,9 @@ class LaunchLib(commands.Cog): def __init__(self, bot: Red): super().__init__() self.bot = bot - self.config = Config.get_conf(self, identifier=0, force_registration=True) + self.config = Config.get_conf( + self, identifier=7697117110991047610598, force_registration=True + ) default_guild = {} @@ -34,10 +36,10 @@ class LaunchLib(commands.Cog): """Nothing to delete""" return - async def _embed_launch_data(self, launch: ll.Launch): - status: ll.LaunchStatus = launch.get_status() + async def _embed_launch_data(self, launch: ll.AsyncLaunch): + status: ll.AsyncLaunchStatus = await launch.get_status() - rocket: ll.Rocket = launch.rocket + rocket: ll.AsyncRocket = launch.rocket title = launch.name description = status.description @@ -105,15 +107,13 @@ class LaunchLib(commands.Cog): @launchlib.command() async def next(self, ctx: commands.Context, num_launches: int = 1): # launches = await api.async_next_launches(num_launches) - loop = asyncio.get_running_loop() - - launches = await loop.run_in_executor( - None, functools.partial(self.api.fetch_launch, num=num_launches) - ) - - # launches = self.api.fetch_launch(num=num_launches) - - print(len(launches)) + # loop = asyncio.get_running_loop() + # + # launches = await loop.run_in_executor( + # None, functools.partial(self.api.fetch_launch, num=num_launches) + # ) + # + launches = await self.api.async_fetch_launch(num=num_launches) async with ctx.typing(): for x, launch in enumerate(launches): diff --git a/lovecalculator/info.json b/lovecalculator/info.json index 543ff92..6425715 100644 --- a/lovecalculator/info.json +++ b/lovecalculator/info.json @@ -4,13 +4,13 @@ "SnappyDragon" ], "min_bot_version": "3.3.0", - "description": "Calculate the love percentage for two users", + "description": "Calculate the love percentage for two users. Shows gif result and description of their love", "hidden": false, "install_msg": "Thank you for installing LoveCalculator. Love is in the air.\n Get started with `[p]load lovecalculator`, then `[p]help LoveCalculator`", "requirements": [ "beautifulsoup4" ], - "short": "Calculate love percentage", + "short": "Calculate love percentage for two users.", "end_user_data_statement": "This cog uses the core Bank cog. It store no End User Data otherwise.", "tags": [ "bobloy", diff --git a/lovecalculator/lovecalculator.py b/lovecalculator/lovecalculator.py index 1b7e5c1..94b6d49 100644 --- a/lovecalculator/lovecalculator.py +++ b/lovecalculator/lovecalculator.py @@ -1,9 +1,13 @@ +import logging + import aiohttp import discord from bs4 import BeautifulSoup from redbot.core import commands from redbot.core.commands import Cog +log = logging.getLogger("red.fox_v3.chatter") + class LoveCalculator(Cog): """Calculate the love percentage for two users!""" @@ -28,17 +32,25 @@ class LoveCalculator(Cog): url = "https://www.lovecalculator.com/love.php?name1={}&name2={}".format( x.replace(" ", "+"), y.replace(" ", "+") ) - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - soup_object = BeautifulSoup(await response.text(), "html.parser") - try: - description = ( - soup_object.find("div", attrs={"class": "result__score"}) - .get_text() - .strip() - ) - except: - description = "Dr. Love is busy right now" + async with aiohttp.ClientSession(headers={"Connection": "keep-alive"}) as session: + async with session.get(url, ssl=False) as response: + assert response.status == 200 + resp = await response.text() + + log.debug(f"{resp=}") + soup_object = BeautifulSoup(resp, "html.parser") + + description = soup_object.find("div", class_="result__score").get_text() + + if description is None: + description = "Dr. Love is busy right now" + else: + description = description.strip() + + result_image = soup_object.find("img", class_="result__image").get("src") + + result_text = soup_object.find("div", class_="result-text").get_text() + result_text = " ".join(result_text.split()) try: z = description[:2] @@ -47,11 +59,15 @@ class LoveCalculator(Cog): emoji = "❤" else: emoji = "💔" - title = "Dr. Love says that the love percentage for {} and {} is:".format(x, y) + title = f"Dr. Love says that the love percentage for {x} and {y} is: {emoji} {description} {emoji}" except: - emoji = "" title = "Dr. Love has left a note for you." - description = emoji + " " + description + " " + emoji - em = discord.Embed(title=title, description=description, color=discord.Color.red()) + em = discord.Embed( + title=title, + description=result_text, + color=discord.Color.red(), + url=f"https://www.lovecalculator.com/{result_image}", + ) + await ctx.send(embed=em) diff --git a/lseen/lseen.py b/lseen/lseen.py index a451f57..3348b65 100644 --- a/lseen/lseen.py +++ b/lseen/lseen.py @@ -75,9 +75,7 @@ class LastSeen(Cog): else: last_seen = await self.config.member(member).seen() if last_seen is None: - await ctx.maybe_send_embed( - embed=discord.Embed(description="I've never seen this user") - ) + await ctx.maybe_send_embed("I've never seen this user") return last_seen = self.get_date_time(last_seen) diff --git a/nudity/nudity.py b/nudity/nudity.py index 0d46ca9..4233460 100644 --- a/nudity/nudity.py +++ b/nudity/nudity.py @@ -85,7 +85,9 @@ class Nudity(commands.Cog): if r["unsafe"] > 0.7: await nsfw_channel.send( "NSFW Image from {}".format(message.channel.mention), - file=discord.File(image,), + file=discord.File( + image, + ), ) @commands.Cog.listener() diff --git a/planttycoon/planttycoon.py b/planttycoon/planttycoon.py index 61e5e06..665fc9a 100644 --- a/planttycoon/planttycoon.py +++ b/planttycoon/planttycoon.py @@ -360,7 +360,9 @@ class PlantTycoon(commands.Cog): ``{0}prune``: Prune your plant.\n""" em = discord.Embed( - title=title, description=description.format(prefix), color=discord.Color.green(), + title=title, + description=description.format(prefix), + color=discord.Color.green(), ) em.set_thumbnail(url="https://image.prntscr.com/image/AW7GuFIBSeyEgkR2W3SeiQ.png") em.set_footer( @@ -525,7 +527,8 @@ class PlantTycoon(commands.Cog): if t: em = discord.Embed( - title="Plant statistics of {}".format(plant["name"]), color=discord.Color.green(), + title="Plant statistics of {}".format(plant["name"]), + color=discord.Color.green(), ) em.set_thumbnail(url=plant["image"]) em.add_field(name="**Name**", value=plant["name"]) @@ -583,7 +586,8 @@ class PlantTycoon(commands.Cog): author = ctx.author if product is None: em = discord.Embed( - title="All gardening supplies that you can buy:", color=discord.Color.green(), + title="All gardening supplies that you can buy:", + color=discord.Color.green(), ) for pd in self.products: em.add_field( @@ -616,8 +620,11 @@ class PlantTycoon(commands.Cog): await gardener.save_gardener() message = "You bought {}.".format(product.lower()) else: - message = "You don't have enough Thneeds. You have {}, but need {}.".format( - gardener.points, self.products[product.lower()]["cost"] * amount, + message = ( + "You don't have enough Thneeds. You have {}, but need {}.".format( + gardener.points, + self.products[product.lower()]["cost"] * amount, + ) ) else: message = "I don't have this product." diff --git a/reactrestrict/reactrestrict.py b/reactrestrict/reactrestrict.py index 4030538..79c3c1c 100644 --- a/reactrestrict/reactrestrict.py +++ b/reactrestrict/reactrestrict.py @@ -1,3 +1,4 @@ +import logging from typing import List, Union import discord @@ -5,6 +6,8 @@ from redbot.core import Config, commands from redbot.core.bot import Red from redbot.core.commands import Cog +log = logging.getLogger("red.fox_v3.reactrestrict") + class ReactRestrictCombo: def __init__(self, message_id, role_id): @@ -131,10 +134,12 @@ class ReactRestrict(Cog): If no such channel or member can be found. """ channel = self.bot.get_channel(channel_id) + if channel is None: + raise LookupError("no channel found.") try: member = channel.guild.get_member(user_id) except AttributeError as e: - raise LookupError("No channel found.") from e + raise LookupError("No member found.") from e if member is None: raise LookupError("No member found.") @@ -168,7 +173,7 @@ class ReactRestrict(Cog): """ channel = self.bot.get_channel(channel_id) try: - return await channel.get_message(message_id) + return await channel.fetch_message(message_id) except discord.NotFound: pass except AttributeError: # VoiceChannel object has no attribute 'get_message' @@ -186,9 +191,11 @@ class ReactRestrict(Cog): :param message_id: :return: """ - for channel in ctx.guild.channels: + + guild: discord.Guild = ctx.guild + for channel in guild.text_channels: try: - return await channel.get_message(message_id) + return await channel.fetch_message(message_id) except discord.NotFound: pass except AttributeError: # VoiceChannel object has no attribute 'get_message' @@ -232,7 +239,7 @@ class ReactRestrict(Cog): # noinspection PyTypeChecker await self.add_reactrestrict(message_id, role) - await ctx.maybe_send_embed("Message|Role combo added.") + await ctx.maybe_send_embed("Message|Role restriction added.") @reactrestrict.command() async def remove(self, ctx: commands.Context, message_id: int, role: discord.Role): @@ -248,37 +255,38 @@ class ReactRestrict(Cog): # noinspection PyTypeChecker await self.remove_react(message_id, role) - await ctx.send("Reaction removed.") + await ctx.send("React restriction removed.") @commands.Cog.listener() - async def on_raw_reaction_add( - self, emoji: discord.PartialEmoji, message_id: int, channel_id: int, user_id: int - ): + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): """ Event handler for long term reaction watching. - - :param discord.PartialReactionEmoji emoji: - :param int message_id: - :param int channel_id: - :param int user_id: - :return: """ - if emoji.is_custom_emoji(): - emoji_id = emoji.id - else: - emoji_id = emoji.name + + emoji = payload.emoji + message_id = payload.message_id + channel_id = payload.channel_id + user_id = payload.user_id + + # if emoji.is_custom_emoji(): + # emoji_id = emoji.id + # else: + # emoji_id = emoji.name has_reactrestrict, combos = await self.has_reactrestrict_combo(message_id) if not has_reactrestrict: + log.debug("Message not react restricted") return try: member = self._get_member(channel_id, user_id) except LookupError: + log.exception("Unable to get member from guild") return if member.bot: + log.debug("Won't remove reactions added by bots") return if await self.bot.cog_disabled_in_guild(self, member.guild): @@ -287,14 +295,19 @@ class ReactRestrict(Cog): try: roles = [self._get_role(member.guild, c.role_id) for c in combos] except LookupError: + log.exception("Couldn't get approved roles from combos") return for apprrole in roles: if apprrole in member.roles: + log.debug("Has approved role") return message = await self._get_message_from_channel(channel_id, message_id) - await message.remove_reaction(emoji, member) + try: + await message.remove_reaction(emoji, member) + except (discord.Forbidden, discord.NotFound, discord.HTTPException): + log.exception("Unable to remove reaction") # try: # await member.add_roles(*roles) diff --git a/rpsls/rpsls.py b/rpsls/rpsls.py index b831d1c..ed2e1dc 100644 --- a/rpsls/rpsls.py +++ b/rpsls/rpsls.py @@ -28,8 +28,8 @@ class RPSLS(Cog): @commands.command() async def rpsls(self, ctx: commands.Context, choice: str): """ - Play Rock Paper Scissors Lizard Spock by Sam Kass in Discord! - + Play Rock Paper Scissors Lizard Spock by Sam Kass in Discord! + Rules: Scissors cuts Paper Paper covers Rock diff --git a/stealemoji/stealemoji.py b/stealemoji/stealemoji.py index b1c7de5..492ef70 100644 --- a/stealemoji/stealemoji.py +++ b/stealemoji/stealemoji.py @@ -1,9 +1,13 @@ +import asyncio +import logging +from typing import Union + import discord from redbot.core import Config, checks, commands from redbot.core.bot import Red from redbot.core.commands import Cog - +log = logging.getLogger("red.fox_v3.stealemoji") # Replaced with discord.Asset.read() # async def fetch_img(session: aiohttp.ClientSession, url: StrOrURL): # async with session.get(url) as response: @@ -43,7 +47,13 @@ class StealEmoji(Cog): super().__init__() self.bot = red self.config = Config.get_conf(self, identifier=11511610197108101109111106105) - default_global = {"stolemoji": {}, "guildbanks": [], "on": False, "notify": 0} + default_global = { + "stolemoji": {}, + "guildbanks": [], + "on": False, + "notify": 0, + "autobank": False, + } self.config.register_global(**default_global) @@ -124,6 +134,17 @@ class StealEmoji(Cog): await ctx.maybe_send_embed("Collection is now " + str(not curr_setting)) + @checks.is_owner() + @stealemoji.command(name="autobank") + async def se_autobank(self, ctx): + """Toggles automatically creating new guilds as emoji banks""" + curr_setting = await self.config.autobank() + await self.config.autobank.set(not curr_setting) + + self.is_on = await self.config.autobank() + + await ctx.maybe_send_embed("AutoBanking is now " + str(not curr_setting)) + @checks.is_owner() @commands.guild_only() @stealemoji.command(name="bank") @@ -193,7 +214,7 @@ class StealEmoji(Cog): # This is now a custom emoji that the bot doesn't have access to, time to steal it # First, do I have an available guildbank? - guildbank = None + guildbank: Union[discord.Guild, None] = None banklist = await self.config.guildbanks() for guild_id in banklist: guild: discord.Guild = self.bot.get_guild(guild_id) @@ -203,9 +224,33 @@ class StealEmoji(Cog): break if guildbank is None: - # print("No guildbank to store emoji") - # Eventually make a new banklist - return + if await self.config.autobank(): + try: + guildbank: discord.Guild = await self.bot.create_guild( + "StealEmoji Guildbank", code="S93bqTqKQ9rM" + ) + except discord.HTTPException: + await self.config.autobank.set(False) + log.exception("Unable to create guilds, disabling autobank") + return + async with self.config.guildbanks() as guildbanks: + guildbanks.append(guildbank.id) + + await asyncio.sleep(2) + + if guildbank.text_channels: + channel = guildbank.text_channels[0] + else: + # Always hits the else. + # Maybe create_guild doesn't return guild object with + # the template channel? + channel = await guildbank.create_text_channel("invite-channel") + invite = await channel.create_invite() + + await self.bot.send_to_owners(invite) + log.info(f"Guild created id {guildbank.id}. Invite: {invite}") + else: + return # Next, have I saved this emoji before (because uploaded emoji != orignal emoji) diff --git a/timerole/info.json b/timerole/info.json index ec74efb..ca9375b 100644 --- a/timerole/info.json +++ b/timerole/info.json @@ -3,10 +3,10 @@ "Bobloy" ], "min_bot_version": "3.3.0", - "description": "Apply roles based on the # of days on server", + "description": "Apply roles based on the # of hours or days on server", "hidden": false, "install_msg": "Thank you for installing timerole.\nGet started with `[p]load timerole`. Configure with `[p]timerole`", - "short": "Apply roles after # of days", + "short": "Apply roles after # of hours or days", "end_user_data_statement": "This cog does not store any End User Data", "tags": [ "bobloy", diff --git a/timerole/timerole.py b/timerole/timerole.py index a103bb3..7484267 100644 --- a/timerole/timerole.py +++ b/timerole/timerole.py @@ -1,12 +1,23 @@ import asyncio +import logging from datetime import datetime, timedelta import discord from redbot.core import Config, checks, commands from redbot.core.bot import Red from redbot.core.commands import Cog, parse_timedelta +from redbot.core.utils import AsyncIter from redbot.core.utils.chat_formatting import pagify +log = logging.getLogger("red.fox_v3.timerole") + + +async def sleep_till_next_hour(): + now = datetime.utcnow() + next_hour = datetime(year=now.year, month=now.month, day=now.day, hour=now.hour + 1) + log.debug("Sleeping for {} seconds".format((next_hour - datetime.utcnow()).seconds)) + await asyncio.sleep((next_hour - datetime.utcnow()).seconds) + class Timerole(Cog): """Add roles to users based on time on server""" @@ -20,7 +31,7 @@ class Timerole(Cog): self.config.register_global(**default_global) self.config.register_guild(**default_guild) - self.updating = asyncio.create_task(self.check_day()) + self.updating = asyncio.create_task(self.check_hour()) async def red_delete_data_for_user(self, **kwargs): """Nothing to delete""" @@ -34,13 +45,14 @@ class Timerole(Cog): @commands.guild_only() async def runtimerole(self, ctx: commands.Context): """ - Trigger the daily timerole + Trigger the hourly timerole Useful for troubleshooting the initial setup """ - await self.timerole_update() - await ctx.tick() + async with ctx.typing(): + await self.timerole_update() + await ctx.tick() @commands.group() @checks.mod_or_permissions(administrator=True) @@ -129,7 +141,7 @@ class Timerole(Cog): guild = ctx.guild role_dict = await self.config.guild(guild).roles() - out = "" + out = "Current Timeroles:\n" for r_id, r_data in role_dict.items(): if r_data is not None: role = discord.utils.get(guild.roles, id=int(r_id)) @@ -141,11 +153,11 @@ class Timerole(Cog): str(discord.utils.get(guild.roles, id=int(new_id))) for new_id in r_data["required"] ] - out += "{} || {} days || requires: {}\n".format(str(role), r_data["days"], r_roles) + out += "{} | {} days | requires: {}\n".format(str(role), r_data["days"], r_roles) await ctx.maybe_send_embed(out) async def timerole_update(self): - for guild in self.bot.guilds: + async for guild in AsyncIter(self.bot.guilds): addlist = [] removelist = [] @@ -153,7 +165,7 @@ class Timerole(Cog): if not any(role_data for role_data in role_dict.values()): # No roles continue - for member in guild.members: + async for member in AsyncIter(guild.members): has_roles = [r.id for r in member.roles] add_roles = [ @@ -188,7 +200,7 @@ class Timerole(Cog): async def announce_roles(self, title, role_list, channel, guild, to_add: True): results = "" - for member, role_id in role_list: + async for member, role_id in AsyncIter(role_list): role = discord.utils.get(guild.roles, id=role_id) try: if to_add: @@ -203,9 +215,11 @@ class Timerole(Cog): await channel.send(title) for page in pagify(results, shorten_by=50): await channel.send(page) + elif results: # Channel is None, log the results + log.info(results) async def check_required_and_date(self, role_list, check_roles, has_roles, member, role_dict): - for role_id in check_roles: + async for role_id in AsyncIter(check_roles): # Check for required role if "required" in role_dict[str(role_id)]: if not set(role_dict[str(role_id)]["required"]) & set(has_roles): @@ -223,7 +237,8 @@ class Timerole(Cog): # Qualifies role_list.append((member, role_id)) - async def check_day(self): + async def check_hour(self): + await sleep_till_next_hour() while self is self.bot.get_cog("Timerole"): await self.timerole_update() - await asyncio.sleep(3600) + await sleep_till_next_hour() diff --git a/tts/tts.py b/tts/tts.py index 1291777..235d585 100644 --- a/tts/tts.py +++ b/tts/tts.py @@ -30,8 +30,8 @@ class TTS(Cog): @commands.command(aliases=["t2s", "text2"]) async def tts(self, ctx: commands.Context, *, text: str): """ - Send Text to speech messages as an mp3 - """ + Send Text to speech messages as an mp3 + """ mp3_fp = io.BytesIO() tts = gTTS(text, lang="en") tts.write_to_fp(mp3_fp) 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, + )