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 RPGs
Lots of additional features are planned, currently function with simple map |
| dad | **Beta** | Tell dad jokes
Works great! |
| exclusiverole | **Alpha** | Prevent certain roles from getting any other roles
Fully functional, but pretty simple |
+| fifo | **Alpha** | Schedule commands to be run at certain times or intervals
Just released, please report bugs as you find them. Only works for bot owner for now |
| fight | **Incomplete** | Organize bracket tournaments within discord
Still 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 time
Ported, will not import old data. Please report bugs |
| forcemention | **Alpha** | Mentions unmentionable roles
Very simple cog, mention doesn't persist |
| hangman | **Beta** | Play a game of hangman
Some visual glitches and needs more customization |
| howdoi | **Incomplete** | Ask coding questions and get results from StackExchange
Not yet functional |
-| infochannel | **Beta** | Create a channel to display server info
Just released, please report bugs |
+| infochannel | **Beta** | Create a channel to display server info
Due to rate limits, this does not update as often as it once did |
+| isitdown | **Beta** | Check if a website/url is down
Just released, please report bugs |
| launchlib | **Beta** | Access rocket launch data
Just released, please report bugs |
| leaver | **Beta** | Send a message in a channel when a user leaves the server
Seems 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 discord
Another 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,
+ )