easterobot 1.1.2__py3-none-any.whl → 1.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
easterobot/games/game.py CHANGED
@@ -1,8 +1,6 @@
1
1
  """Base class for game."""
2
2
 
3
3
  import asyncio
4
- import contextlib
5
- import datetime
6
4
  import logging
7
5
  from collections.abc import Coroutine
8
6
  from dataclasses import dataclass
@@ -12,11 +10,11 @@ from uuid import uuid4
12
10
  import discord
13
11
  from discord.ext import commands
14
12
  from discord.message import convert_emoji_reaction
15
- from discord.utils import format_dt
16
13
 
17
14
  from easterobot.bot import Easterobot
18
15
  from easterobot.commands.base import Context, Interaction, InteractionChannel
19
16
  from easterobot.config import RAND
17
+ from easterobot.utils import in_seconds
20
18
 
21
19
  logger = logging.getLogger(__name__)
22
20
  AsyncCallback = Callable[[], Coroutine[Any, Any, None]]
@@ -59,8 +57,11 @@ class Game:
59
57
  self._cleanup: Optional[AsyncCallback] = None
60
58
  self._completion: Optional[AsyncCallback] = None
61
59
  self._end_event = asyncio.Event()
60
+
61
+ # timeout
62
62
  self._reset_countdown_event = asyncio.Event()
63
63
  self._timeout_task: Optional[asyncio.Task[None]] = None
64
+ self._timeout_lock: asyncio.Lock = asyncio.Lock()
64
65
 
65
66
  async def set_completion(self, callback: AsyncCallback) -> None:
66
67
  """Get the current state for a player."""
@@ -94,20 +95,33 @@ class Game:
94
95
  await self._completion()
95
96
  self._end_event.set()
96
97
 
97
- def start_timer(self, seconds: float) -> str:
98
+ async def start_timer(self, seconds: float) -> str:
98
99
  """Start the timer for turn."""
99
- now = datetime.datetime.now() + datetime.timedelta(seconds=seconds) # noqa: DTZ005
100
- dt = format_dt(now, style="R")
101
- self._timeout_task = asyncio.create_task(self._timeout_worker(seconds))
102
- return dt
100
+ async with self._timeout_lock:
101
+ logger.info(
102
+ "Start timer of %s seconds for %s",
103
+ seconds,
104
+ self,
105
+ )
106
+ self._timeout_task = asyncio.create_task(
107
+ self._timeout_worker(seconds)
108
+ )
109
+ return in_seconds(seconds)
103
110
 
104
111
  async def stop_timer(self) -> None:
105
112
  """Stop the timer and wait it end."""
106
- if self._timeout_task:
107
- self._reset_countdown_event.set()
108
- await self._timeout_task
109
- self._timeout_task = None
110
- self._reset_countdown_event = asyncio.Event()
113
+ async with self._timeout_lock:
114
+ logger.info("Stop timer for %s", self)
115
+ if (
116
+ self._timeout_task
117
+ and not self._timeout_task.done()
118
+ and not self._timeout_task.cancelled()
119
+ ):
120
+ self._reset_countdown_event.set()
121
+ await self._timeout_task
122
+ self._timeout_task = None
123
+ self._reset_countdown_event = asyncio.Event()
124
+ logger.info("Timer stopped for %s", self)
111
125
 
112
126
  async def _timeout_worker(self, seconds: float) -> None:
113
127
  """Timeout action."""
@@ -117,13 +131,14 @@ class Game:
117
131
  except asyncio.TimeoutError:
118
132
  if not event.is_set():
119
133
  async with self.lock:
134
+ logger.info("Timeout for %s", self)
120
135
  await self.on_timeout()
121
136
 
122
137
  def __repr__(self) -> str:
123
138
  """Get game representation."""
124
139
  return (
125
140
  f"<{self.__class__.__qualname__} "
126
- f"id={self.id!r} message={self.message!r} "
141
+ f"id={str(self.id)!r} message={self.message.id!r} "
127
142
  f"terminate={self.terminate!r} winner={self.winner!r}"
128
143
  ">"
129
144
  )
@@ -152,13 +167,12 @@ class GameCog(commands.Cog):
152
167
  from easterobot.games.tic_tac_toe import TicTacToe
153
168
 
154
169
  cls = RAND.choice([Connect4, TicTacToe, RockPaperScissor])
155
- now = datetime.datetime.now() + datetime.timedelta(seconds=63) # noqa: DTZ005
156
- dt = format_dt(now, style="R")
157
170
  msg = await channel.send(
158
- f"{user1.mention} et {user2.mention} vont s'affronter {dt} ...",
171
+ f"{user1.mention} et {user2.mention} "
172
+ f"vont s'affronter {in_seconds(121)} ...",
159
173
  reference=reference,
160
174
  )
161
- await asyncio.sleep(63)
175
+ await asyncio.sleep(121)
162
176
  game: Game = cls(user1, user2, msg) # type: ignore[operator]
163
177
  await self.run(game)
164
178
  return await game.wait_winner()
@@ -172,7 +186,13 @@ class GameCog(commands.Cog):
172
186
  del self._games[message_id]
173
187
  else:
174
188
  logger.warning("Missing game: %s", game)
175
- await game.message.clear_reactions()
189
+ try:
190
+ await game.message.clear_reactions()
191
+ except discord.Forbidden:
192
+ logger.warning(
193
+ "Missing permission for remove all reactions from %s",
194
+ message_id,
195
+ )
176
196
 
177
197
  self._games[message_id] = game
178
198
  game._cleanup = _cleanup # noqa: SLF001
@@ -186,8 +206,8 @@ class GameCog(commands.Cog):
186
206
  bet: int,
187
207
  ) -> Optional[discord.Message]:
188
208
  """Send basic message for initialization."""
189
- future: asyncio.Future[bool] = asyncio.Future()
190
- accept = False
209
+ future: asyncio.Future[Optional[bool]] = asyncio.Future()
210
+ accept: Optional[bool] = None
191
211
 
192
212
  view = discord.ui.View()
193
213
  yes_btn: Button = discord.ui.Button(
@@ -198,44 +218,74 @@ class GameCog(commands.Cog):
198
218
  )
199
219
 
200
220
  async def yes(interaction: Interaction) -> Any:
201
- if interaction.user.id == member.id:
202
- future.set_result(True)
203
221
  await interaction.response.defer()
222
+ if not future.done() and interaction.user.id == member.id:
223
+ future.set_result(True)
204
224
 
205
225
  async def no(interaction: Interaction) -> Any:
206
- if interaction.user.id == member.id:
207
- future.set_result(False)
208
226
  await interaction.response.defer()
227
+ if not future.done():
228
+ if interaction.user.id == member.id:
229
+ future.set_result(False)
230
+ if interaction.user.id == ctx.user.id:
231
+ future.set_result(None)
209
232
 
210
233
  yes_btn.callback = yes # type: ignore[method-assign,assignment]
211
234
  no_btn.callback = no # type: ignore[method-assign,assignment]
212
235
  view.add_item(yes_btn)
213
236
  view.add_item(no_btn)
214
- now = datetime.datetime.now() + datetime.timedelta(seconds=180) # noqa: DTZ005
215
- dt = format_dt(now, style="R")
237
+ seconds = 600
216
238
  result = await ctx.response.send_message(
217
239
  f"{member.mention}, {ctx.user.mention} "
218
240
  f"vous demande en duel pour `{bet}` œufs ⚔️"
219
- f"\nVous devez repondre {dt} !",
241
+ f"\nVous devez repondre {in_seconds(seconds)} !",
220
242
  view=view,
221
243
  )
222
244
  message = result.resource
223
245
  if not isinstance(message, discord.Message):
224
246
  error_message = f"Invalid kind of message: {message!r}"
225
247
  raise TypeError(error_message)
226
- with contextlib.suppress(asyncio.TimeoutError):
227
- accept = await asyncio.wait_for(future, timeout=180)
248
+ try:
249
+ accept = await asyncio.wait_for(future, timeout=seconds)
250
+ except asyncio.TimeoutError:
251
+ await message.edit(
252
+ content=(
253
+ f"{ctx.user.mention}, "
254
+ f"{member.mention} n'a pas accepté le duel 🛡️"
255
+ f"\n-# Ce message disparaîtra {in_seconds(30)}"
256
+ ),
257
+ delete_after=30,
258
+ view=None,
259
+ )
260
+ return None
261
+ if accept is None:
262
+ await message.edit(
263
+ content=(
264
+ f"{member.mention}, {ctx.user.mention} a annulé le duel 🛡️"
265
+ f"\n-# Ce message disparaîtra {in_seconds(30)}"
266
+ ),
267
+ delete_after=30,
268
+ view=None,
269
+ )
270
+ return None
228
271
  if not accept:
229
272
  await message.edit(
230
273
  content=(
231
- f"{member.mention}, {ctx.user.mention} a refusé le duel 🛡️"
274
+ f"{ctx.user.mention}, {member.mention} a refusé le duel 🛡️"
275
+ f"\n-# Ce message disparaîtra {in_seconds(30)}"
232
276
  ),
277
+ delete_after=30,
233
278
  view=None,
234
279
  )
235
280
  return None
236
281
  if not isinstance(result.resource, discord.Message):
237
282
  error_message = f"Invalid kind of message: {result.resource!r}"
238
283
  raise TypeError(error_message)
284
+ await result.resource.reply(
285
+ f"{ctx.user.mention}, {member.mention} a accepté le duel ⚔️"
286
+ f"\n-# Ce message disparaîtra {in_seconds(30)}",
287
+ delete_after=30,
288
+ )
239
289
  return result.resource
240
290
 
241
291
  @commands.Cog.listener()
@@ -249,14 +299,35 @@ class GameCog(commands.Cog):
249
299
  return
250
300
  if payload.message_id in self._games:
251
301
  # Use connection for faster remove
252
- emoji = convert_emoji_reaction(payload.emoji)
253
302
  game = self._games[payload.message_id]
254
303
  await asyncio.gather(
255
- self.bot._connection.http.remove_reaction( # noqa: SLF001
304
+ self.silent_reaction_remove(
256
305
  payload.channel_id,
257
306
  payload.message_id,
258
- emoji,
307
+ payload.emoji,
259
308
  payload.user_id,
260
309
  ),
261
310
  game.on_reaction(payload.user_id, payload.emoji),
262
311
  )
312
+
313
+ async def silent_reaction_remove(
314
+ self,
315
+ channel_id: int,
316
+ message_id: int,
317
+ emoji: discord.PartialEmoji,
318
+ user_id: int,
319
+ ) -> None:
320
+ """Handle reaction."""
321
+ try:
322
+ reaction = convert_emoji_reaction(emoji)
323
+ await self.bot._connection.http.remove_reaction( # noqa: SLF001
324
+ channel_id,
325
+ message_id,
326
+ reaction,
327
+ user_id,
328
+ )
329
+ except discord.Forbidden:
330
+ logger.warning(
331
+ "Missing permission for remove reaction from %s",
332
+ message_id,
333
+ )
@@ -1,5 +1,6 @@
1
1
  """TicTacToe."""
2
2
 
3
+ import asyncio
3
4
  from functools import partial
4
5
  from typing import Optional
5
6
 
@@ -44,7 +45,7 @@ class RockPaperScissor(Game):
44
45
  embed.set_author(
45
46
  name="Partie en cours", icon_url=self.bot.app_emojis["wait"].url
46
47
  )
47
- self.view = discord.ui.View()
48
+ self.view = discord.ui.View(timeout=1800)
48
49
  rock_btn: Button = discord.ui.Button(
49
50
  style=discord.ButtonStyle.gray,
50
51
  emoji=ROCK,
@@ -76,7 +77,10 @@ class RockPaperScissor(Game):
76
77
 
77
78
  # One button has been pressed
78
79
  if update:
79
- await interaction.response.defer()
80
+ await asyncio.gather(
81
+ self.stop_timer(),
82
+ interaction.response.defer(),
83
+ )
80
84
  await self.update()
81
85
  else:
82
86
  await interaction.response.send_message(
@@ -93,12 +97,18 @@ class RockPaperScissor(Game):
93
97
  self.view.add_item(scissor_btn)
94
98
  await self.update()
95
99
 
96
- async def update(self) -> None: # noqa: PLR0912, PLR0915
100
+ async def update(self) -> None: # noqa: C901, PLR0912, PLR0915
97
101
  """Update the current display."""
98
102
  embed = discord.Embed(color=0xF2BC32)
99
103
  # Both player have played
100
104
  header = "Partie en cours"
101
- if self.play1 is None:
105
+ if self.play1 is None and self.play2 is None:
106
+ icon_url = self.bot.app_emojis["wait"].url
107
+ info = (
108
+ f"En attente de {self.player1.mention} "
109
+ f"et {self.player2.mention} ..."
110
+ )
111
+ elif self.play1 is None:
102
112
  icon_url = self.player1.display_avatar.url
103
113
  info = f"En attente de {self.player1.mention} ..."
104
114
  elif self.play2 is None:
@@ -120,10 +130,10 @@ class RockPaperScissor(Game):
120
130
  for play1, play2 in self.history:
121
131
  i1 = EMOJIS.index(play1)
122
132
  i2 = EMOJIS.index(play2)
123
- if i1 == (i2 + 1) % 3:
133
+ if i1 == (i2 - 1) % 3:
124
134
  pt1 += 1
125
135
  text = self.player1.mention
126
- elif i1 == (i2 - 1) % 3:
136
+ elif i1 == (i2 + 1) % 3:
127
137
  pt2 += 1
128
138
  text = self.player2.mention
129
139
  else:
@@ -166,10 +176,14 @@ class RockPaperScissor(Game):
166
176
  if not self.timeout:
167
177
  await self.set_winner(final_winner)
168
178
  else:
169
- dt = self.start_timer(32)
179
+ dt = await self.start_timer(31)
170
180
  embed.description += f"\n\n{info}\n\nFin du tour {dt}"
171
181
  embed.set_author(name=header, icon_url=icon_url)
172
- await self.message.edit(embed=embed, view=self.view, content="")
182
+ await self.message.edit(
183
+ embed=embed,
184
+ view=self.view,
185
+ content=(f"-# {self.player1.mention} {self.player2.mention}"),
186
+ )
173
187
 
174
188
  def compute_winner(
175
189
  self, play1: str, play2: str
@@ -1,13 +1,12 @@
1
1
  """TicTacToe."""
2
2
 
3
- import datetime
4
3
  from typing import Optional
5
4
 
6
5
  import discord
7
- from discord.utils import format_dt
8
6
  from typing_extensions import override
9
7
 
10
8
  from easterobot.games.game import Game, Player
9
+ from easterobot.utils import in_seconds
11
10
 
12
11
  EMOJIS_MAPPER = {
13
12
  "1️⃣": 0,
@@ -44,7 +43,7 @@ class TicTacToe(Game):
44
43
  await self.update()
45
44
  for emoji in EMOJIS:
46
45
  await self.message.add_reaction(emoji)
47
- self.start_timer(60)
46
+ await self.start_timer(31)
48
47
 
49
48
  async def update(self) -> None:
50
49
  """Update the message."""
@@ -85,8 +84,7 @@ class TicTacToe(Game):
85
84
  content += footer
86
85
 
87
86
  if not self.terminate:
88
- now = datetime.datetime.now() + datetime.timedelta(seconds=32) # noqa: DTZ005
89
- content += f"\n\nFin du tour {format_dt(now, style='R')}"
87
+ content += f"\n\nFin du tour {in_seconds(31)}"
90
88
 
91
89
  embed = discord.Embed(description=content, color=self.color(user))
92
90
  embed.set_author(
@@ -99,7 +97,10 @@ class TicTacToe(Game):
99
97
  )
100
98
  self.message = await self.message.edit(
101
99
  embed=embed,
102
- content="",
100
+ content=(
101
+ f"-# {self.player1.member.mention} "
102
+ f"{self.player2.member.mention}"
103
+ ),
103
104
  view=None,
104
105
  )
105
106
 
@@ -142,7 +143,7 @@ class TicTacToe(Game):
142
143
  await self.set_winner(None)
143
144
  else:
144
145
  self.turn += 1
145
- self.start_timer(30)
146
+ await self.start_timer(31)
146
147
  await self.update()
147
148
 
148
149
  @override