Skip to content

Commit df59c84

Browse files
committed
Madlibs - Add "End Game" and "Choose for me" buttons
1 parent 58fb7d6 commit df59c84

File tree

2 files changed

+502
-13
lines changed

2 files changed

+502
-13
lines changed

bot/exts/fun/madlibs.py

Lines changed: 164 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import json
23
from pathlib import Path
34
from random import choice
@@ -9,11 +10,11 @@
910
from bot.bot import Bot
1011
from bot.constants import Colours, NEGATIVE_REPLIES
1112

12-
TIMEOUT = 60.0
13+
TIMEOUT = 120
1314

1415

1516
class MadlibsTemplate(TypedDict):
16-
"""Structure of a template in the madlibs JSON file."""
17+
"""Structure of a template in the madlibs_templates JSON file."""
1718

1819
title: str
1920
blanks: list[str]
@@ -27,6 +28,10 @@ def __init__(self, bot: Bot):
2728
self.bot = bot
2829
self.templates = self._load_templates()
2930
self.edited_content = {}
31+
self.submitted_words = {}
32+
self.view = None
33+
self.wait_task: asyncio.Task | None = None
34+
self.end_game = False
3035
self.checks = set()
3136

3237
@staticmethod
@@ -43,7 +48,9 @@ def madlibs_embed(part_of_speech: str, number_of_inputs: int) -> discord.Embed:
4348

4449
madlibs_embed.add_field(
4550
name="Enter a word that fits the given part of speech!",
46-
value=f"Part of speech: {part_of_speech}\n\nMake sure not to spam, or you may get auto-muted!"
51+
value=f"Part of speech: {part_of_speech}\n\nMake sure not to spam, or you may get auto-muted!\n\n"
52+
f"Note: You'll be able to use the 'Choose for me' button\none minute after each new part "
53+
f"of speech appears."
4754
)
4855

4956
madlibs_embed.set_footer(text=f"Inputs remaining: {number_of_inputs}")
@@ -73,8 +80,15 @@ async def madlibs(self, ctx: commands.Context) -> None:
7380
"""
7481
random_template = choice(self.templates)
7582

83+
self.end_game = False
84+
7685
def author_check(message: discord.Message) -> bool:
77-
return message.channel.id == ctx.channel.id and message.author.id == ctx.author.id
86+
if message.channel.id != ctx.channel.id or message.author.id != ctx.author.id:
87+
return False
88+
89+
# Ignore commands while a game is running
90+
prefix = ctx.prefix or ""
91+
return not (prefix and message.content.startswith(prefix))
7892

7993
self.checks.add(author_check)
8094

@@ -83,17 +97,49 @@ def author_check(message: discord.Message) -> bool:
8397
)
8498
original_message = await ctx.send(embed=loading_embed)
8599

86-
submitted_words = {}
87-
88100
for i, part_of_speech in enumerate(random_template["blanks"]):
89101
inputs_left = len(random_template["blanks"]) - i
90102

103+
if self.view and getattr(self.view, "cooldown_task", None) and not self.view.cooldown_task.done():
104+
self.view.cooldown_task.cancel()
105+
106+
self.view = MadlibsView(ctx, self, 60, part_of_speech, i)
107+
91108
madlibs_embed = self.madlibs_embed(part_of_speech, inputs_left)
92-
await original_message.edit(embed=madlibs_embed)
109+
await original_message.edit(embed=madlibs_embed, view=self.view)
93110

111+
self.view.cooldown_task = asyncio.create_task(self.view.enable_random_button_after(original_message))
112+
113+
self.wait_task = asyncio.create_task(
114+
self.bot.wait_for("message", timeout=TIMEOUT, check=author_check)
115+
)
94116
try:
95-
message = await self.bot.wait_for("message", check=author_check, timeout=TIMEOUT)
117+
message = await self.wait_task
118+
self.submitted_words[i] = message.content
119+
except asyncio.CancelledError:
120+
if self.end_game:
121+
if self.view:
122+
self.view.stop()
123+
for child in self.view.children:
124+
if isinstance(child, discord.ui.Button):
125+
child.disabled = True
126+
127+
# cancel cooldown cleanly
128+
task = getattr(self.view, "cooldown_task", None)
129+
if task and not task.done():
130+
task.cancel()
131+
132+
await original_message.edit(view=self.view)
133+
self.checks.remove(author_check)
134+
135+
return
136+
# else: "Choose for me" set self.submitted_words[i]; just continue
96137
except TimeoutError:
138+
# If we ended the game around the same time, don't show timeout
139+
if self.end_game:
140+
self.checks.remove(author_check)
141+
return
142+
97143
timeout_embed = discord.Embed(
98144
title=choice(NEGATIVE_REPLIES),
99145
description="Uh oh! You took too long to respond!",
@@ -102,16 +148,24 @@ def author_check(message: discord.Message) -> bool:
102148

103149
await ctx.send(ctx.author.mention, embed=timeout_embed)
104150

105-
for msg_id in submitted_words:
106-
self.edited_content.pop(msg_id, submitted_words[msg_id])
151+
self.view.stop()
152+
for child in self.view.children:
153+
if isinstance(child, discord.ui.Button):
154+
child.disabled = True
155+
156+
await original_message.edit(view=self.view)
157+
158+
for j in self.submitted_words:
159+
self.edited_content.pop(j, self.submitted_words[j])
107160

108161
self.checks.remove(author_check)
109162

110163
return
164+
finally:
165+
# Clean up so the next iteration doesn't see an old task
166+
self.wait_task = None
111167

112-
submitted_words[message.id] = message.content
113-
114-
blanks = [self.edited_content.pop(msg_id, submitted_words[msg_id]) for msg_id in submitted_words]
168+
blanks = [self.submitted_words[j] for j in range(len(random_template["blanks"]))]
115169

116170
self.checks.remove(author_check)
117171

@@ -134,6 +188,20 @@ def author_check(message: discord.Message) -> bool:
134188

135189
await ctx.send(embed=story_embed)
136190

191+
# After sending the story, disable the view and cancel all wait tasks
192+
if self.view:
193+
task = getattr(self.view, "cooldown_task", None)
194+
if task and not task.done():
195+
task.cancel()
196+
self.view.stop()
197+
for child in self.view.children:
198+
if isinstance(child, discord.ui.Button):
199+
child.disabled = True
200+
await original_message.edit(view=self.view)
201+
202+
if self.wait_task and not self.wait_task.done():
203+
self.wait_task.cancel()
204+
137205
@madlibs.error
138206
async def handle_madlibs_error(self, ctx: commands.Context, error: commands.CommandError) -> None:
139207
"""Error handler for the Madlibs command."""
@@ -142,6 +210,89 @@ async def handle_madlibs_error(self, ctx: commands.Context, error: commands.Comm
142210
error.handled = True
143211

144212

213+
class MadlibsView(discord.ui.View):
214+
"""A set of buttons to control a Madlibs game."""
215+
216+
def __init__(self, ctx: commands.Context, cog: "Madlibs", cooldown: float = 0,
217+
part_of_speech: str = "", index: int = 0):
218+
super().__init__(timeout=120)
219+
self.disabled = None
220+
self.ctx = ctx
221+
self.cog = cog
222+
self.word_bank = self._load_word_bank()
223+
self.part_of_speech = part_of_speech
224+
self.index = index
225+
self._cooldown = cooldown
226+
227+
# Reference to the async task that will re-enable the button
228+
self.cooldown_task: asyncio.Task | None = None
229+
230+
if cooldown > 0:
231+
self.random_word_button.disabled = True
232+
233+
async def enable_random_button_after(self, message: discord.Message) -> None:
234+
"""Function that controls the cooldown of the "Choose for me" button to prevent spam."""
235+
if self._cooldown <= 0:
236+
return
237+
await asyncio.sleep(self._cooldown)
238+
239+
# Game ended or this view is no longer the active one
240+
if self.is_finished() or self is not self.cog.view:
241+
return
242+
243+
self.random_word_button.disabled = False
244+
await message.edit(view=self)
245+
246+
@staticmethod
247+
def _load_word_bank() -> dict[str, list[str]]:
248+
word_bank = Path("bot/resources/fun/madlibs_word_bank.json")
249+
250+
with open(word_bank) as file:
251+
return json.load(file)
252+
253+
@discord.ui.button(style=discord.ButtonStyle.green, label="Choose for me")
254+
async def random_word_button(self, interaction: discord.Interaction, *_) -> None:
255+
"""Button that randomly chooses a word for the user if they cannot think of a word."""
256+
if interaction.user == self.ctx.author:
257+
random_word = choice(self.word_bank[self.part_of_speech])
258+
self.cog.submitted_words[self.index] = random_word
259+
260+
wait_task = getattr(self.cog, "wait_task", None)
261+
if wait_task and not wait_task.done():
262+
wait_task.cancel()
263+
264+
if self.cooldown_task and not self.cooldown_task.done():
265+
self.cooldown_task.cancel()
266+
267+
await interaction.response.send_message(f"Randomly chosen word: {random_word}", ephemeral=True)
268+
269+
# Re-disable the button and restart the cooldown (so it can't be clicked again immediately)
270+
self.random_word_button.disabled = True
271+
await interaction.followup.edit_message(view=self)
272+
else:
273+
await interaction.response.send_message("Only the owner of the game can end it!", ephemeral=True)
274+
275+
@discord.ui.button(style=discord.ButtonStyle.red, label="End Game")
276+
async def end_button(self, interaction: discord.Interaction, *_) -> None:
277+
"""Button that ends the current game."""
278+
if interaction.user == self.ctx.author:
279+
# Cancel the wait task if it's running
280+
self.cog.end_game = True
281+
wait_task = getattr(self.cog, "wait_task", None)
282+
if wait_task and not wait_task.done():
283+
wait_task.cancel()
284+
285+
# Disable all buttons in the view
286+
for child in self.children:
287+
if isinstance(child, discord.ui.Button):
288+
child.disabled = True
289+
290+
await interaction.response.send_message("Ended the current game.", ephemeral=True)
291+
await interaction.followup.edit_message(message_id=interaction.message.id, view=self)
292+
else:
293+
await interaction.response.send_message("Only the owner of the game can end it!", ephemeral=True)
294+
295+
145296
async def setup(bot: Bot) -> None:
146297
"""Load the Madlibs cog."""
147298
await bot.add_cog(Madlibs(bot))

0 commit comments

Comments
 (0)