easterobot 1.3.1__py3-none-any.whl → 1.5.1__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.
Files changed (83) hide show
  1. easterobot/bot.py +14 -1
  2. easterobot/casino/__init__.py +1 -0
  3. easterobot/casino/roulette.py +269 -0
  4. easterobot/commands/__init__.py +2 -0
  5. easterobot/commands/game.py +127 -118
  6. easterobot/commands/info.py +2 -2
  7. easterobot/commands/reset.py +11 -14
  8. easterobot/commands/roulette.py +34 -0
  9. easterobot/commands/top.py +73 -65
  10. easterobot/config.py +35 -8
  11. easterobot/games/{connect.py → connect4.py} +25 -28
  12. easterobot/games/game.py +126 -54
  13. easterobot/games/rock_paper_scissor.py +33 -30
  14. easterobot/games/skyjo.py +805 -0
  15. easterobot/games/tic_tac_toe.py +19 -18
  16. easterobot/hunts/hunt.py +49 -18
  17. easterobot/hunts/luck.py +15 -10
  18. easterobot/hunts/rank.py +24 -2
  19. easterobot/locker.py +180 -0
  20. easterobot/models.py +9 -0
  21. easterobot/resources/config.example.yml +18 -12
  22. easterobot/resources/credits.txt +2 -0
  23. easterobot/resources/emotes/placements/s1.png +0 -0
  24. easterobot/resources/emotes/placements/s10.png +0 -0
  25. easterobot/resources/emotes/placements/s11.png +0 -0
  26. easterobot/resources/emotes/placements/s12.png +0 -0
  27. easterobot/resources/emotes/placements/s2.png +0 -0
  28. easterobot/resources/emotes/placements/s3.png +0 -0
  29. easterobot/resources/emotes/placements/s4.png +0 -0
  30. easterobot/resources/emotes/placements/s5.png +0 -0
  31. easterobot/resources/emotes/placements/s6.png +0 -0
  32. easterobot/resources/emotes/placements/s7.png +0 -0
  33. easterobot/resources/emotes/placements/s8.png +0 -0
  34. easterobot/resources/emotes/placements/s9.png +0 -0
  35. easterobot/resources/emotes/placements/sA.png +0 -0
  36. easterobot/resources/emotes/placements/sB.png +0 -0
  37. easterobot/resources/emotes/placements/sC.png +0 -0
  38. easterobot/resources/emotes/placements/sD.png +0 -0
  39. easterobot/resources/emotes/placements/sE.png +0 -0
  40. easterobot/resources/emotes/placements/sF.png +0 -0
  41. easterobot/resources/emotes/placements/sG.png +0 -0
  42. easterobot/resources/emotes/placements/sH.png +0 -0
  43. easterobot/resources/emotes/placements/sI.png +0 -0
  44. easterobot/resources/emotes/placements/sJ.png +0 -0
  45. easterobot/resources/emotes/placements/sK.png +0 -0
  46. easterobot/resources/emotes/placements/sL.png +0 -0
  47. easterobot/resources/emotes/placements/sM.png +0 -0
  48. easterobot/resources/emotes/placements/sN.png +0 -0
  49. easterobot/resources/emotes/placements/sO.png +0 -0
  50. easterobot/resources/emotes/placements/sP.png +0 -0
  51. easterobot/resources/emotes/placements/sQ.png +0 -0
  52. easterobot/resources/emotes/placements/sR.png +0 -0
  53. easterobot/resources/emotes/placements/sS.png +0 -0
  54. easterobot/resources/emotes/placements/sT.png +0 -0
  55. easterobot/resources/emotes/placements/sU.png +0 -0
  56. easterobot/resources/emotes/placements/sV.png +0 -0
  57. easterobot/resources/emotes/placements/sW.png +0 -0
  58. easterobot/resources/emotes/placements/sX.png +0 -0
  59. easterobot/resources/emotes/placements/sY.png +0 -0
  60. easterobot/resources/emotes/placements/sZ.png +0 -0
  61. easterobot/resources/emotes/placements/s_.png +0 -0
  62. easterobot/resources/emotes/skyjo/skyjo_back.png +0 -0
  63. easterobot/resources/emotes/skyjo/skyjo_m1.png +0 -0
  64. easterobot/resources/emotes/skyjo/skyjo_m2.png +0 -0
  65. easterobot/resources/emotes/skyjo/skyjo_p0.png +0 -0
  66. easterobot/resources/emotes/skyjo/skyjo_p1.png +0 -0
  67. easterobot/resources/emotes/skyjo/skyjo_p10.png +0 -0
  68. easterobot/resources/emotes/skyjo/skyjo_p11.png +0 -0
  69. easterobot/resources/emotes/skyjo/skyjo_p12.png +0 -0
  70. easterobot/resources/emotes/skyjo/skyjo_p2.png +0 -0
  71. easterobot/resources/emotes/skyjo/skyjo_p3.png +0 -0
  72. easterobot/resources/emotes/skyjo/skyjo_p4.png +0 -0
  73. easterobot/resources/emotes/skyjo/skyjo_p5.png +0 -0
  74. easterobot/resources/emotes/skyjo/skyjo_p6.png +0 -0
  75. easterobot/resources/emotes/skyjo/skyjo_p7.png +0 -0
  76. easterobot/resources/emotes/skyjo/skyjo_p8.png +0 -0
  77. easterobot/resources/emotes/skyjo/skyjo_p9.png +0 -0
  78. {easterobot-1.3.1.dist-info → easterobot-1.5.1.dist-info}/METADATA +1 -1
  79. easterobot-1.5.1.dist-info/RECORD +130 -0
  80. easterobot-1.3.1.dist-info/RECORD +0 -70
  81. {easterobot-1.3.1.dist-info → easterobot-1.5.1.dist-info}/WHEEL +0 -0
  82. {easterobot-1.3.1.dist-info → easterobot-1.5.1.dist-info}/entry_points.txt +0 -0
  83. {easterobot-1.3.1.dist-info → easterobot-1.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,10 +1,12 @@
1
1
  """TicTacToe."""
2
2
 
3
+ import asyncio
3
4
  from typing import Optional
4
5
 
5
6
  import discord
6
7
  from typing_extensions import override
7
8
 
9
+ from easterobot.bot import Easterobot
8
10
  from easterobot.games.game import Game, Player
9
11
  from easterobot.utils import in_seconds
10
12
 
@@ -25,25 +27,24 @@ EMOJIS = tuple(EMOJIS_MAPPER)
25
27
  class TicTacToe(Game):
26
28
  def __init__(
27
29
  self,
28
- player1: discord.Member,
29
- player2: discord.Member,
30
+ bot: Easterobot,
30
31
  message: discord.Message,
32
+ *members: discord.Member,
31
33
  ) -> None:
32
34
  """Initialize grid."""
33
35
  self.grid: list[Optional[Player]] = [None] * 9
34
36
  self.timeout = False
35
- self.player1 = Player(player1, 1)
36
- self.player2 = Player(player2, 2)
37
37
  self.turn = 0
38
- super().__init__(message)
38
+ super().__init__(bot, message, *members)
39
39
 
40
40
  @override
41
41
  async def on_start(self) -> None:
42
42
  """Run."""
43
43
  await self.update()
44
+ await self.start_timer(31)
44
45
  for emoji in EMOJIS:
46
+ await asyncio.sleep(0.1)
45
47
  await self.message.add_reaction(emoji)
46
- await self.start_timer(31)
47
48
 
48
49
  async def update(self) -> None:
49
50
  """Update the message."""
@@ -55,13 +56,13 @@ class TicTacToe(Game):
55
56
  user: Optional[Player] = self.current
56
57
  elif self.winner:
57
58
  forfait = "par forfait " if self.timeout else ""
58
- footer = f"\n## Gagnant {forfait}{self.winner.mention} 🎉"
59
+ footer = f"\n## Gagnant {forfait}{self.winner.member.mention} 🎉"
59
60
  user = self.current
60
61
  else:
61
62
  footer = (
62
63
  "\n## Égalité entre "
63
- f"{self.player1.member.mention} "
64
- f"et {self.player2.member.mention} 🤝"
64
+ f"{self.players[0].member.mention} "
65
+ f"et {self.players[1].member.mention} 🤝"
65
66
  )
66
67
  user = None
67
68
 
@@ -72,9 +73,9 @@ class TicTacToe(Game):
72
73
  for col in range(3):
73
74
  index = row * 3 + col
74
75
  player = self.grid[index]
75
- if player == self.player1:
76
+ if player == self.players[0]:
76
77
  piece = "❌"
77
- elif player == self.player2:
78
+ elif player == self.players[1]:
78
79
  piece = "⭕"
79
80
  else:
80
81
  piece = EMOJIS[index]
@@ -98,8 +99,8 @@ class TicTacToe(Game):
98
99
  self.message = await self.message.edit(
99
100
  embed=embed,
100
101
  content=(
101
- f"-# {self.player1.member.mention} "
102
- f"{self.player2.member.mention}"
102
+ f"-# {self.players[0].member.mention} "
103
+ f"{self.players[1].member.mention}"
103
104
  ),
104
105
  view=None,
105
106
  )
@@ -116,15 +117,15 @@ class TicTacToe(Game):
116
117
  @property
117
118
  def current(self) -> Player:
118
119
  """Get current member."""
119
- return [self.player1, self.player2][self.turn % 2]
120
+ return [self.players[0], self.players[1]][self.turn % 2]
120
121
 
121
122
  def color(self, player: Optional[Player]) -> Optional[discord.Colour]:
122
123
  """Color of the embed."""
123
124
  if player is None:
124
125
  return discord.Colour.from_str("#d4d5d6")
125
- if player == self.player1:
126
+ if player == self.players[0]:
126
127
  return discord.Colour.from_str("#F17720")
127
- if player == self.player2:
128
+ if player == self.players[1]:
128
129
  return discord.Colour.from_str("#0474BA")
129
130
  error_message = f"Invalid player: {player!r}"
130
131
  raise ValueError(error_message)
@@ -138,7 +139,7 @@ class TicTacToe(Game):
138
139
  self.grid[index] = player
139
140
 
140
141
  if self._is_winner(player):
141
- await self.set_winner(player.member)
142
+ await self.set_winner(player)
142
143
  elif all(cell is not None for cell in self.grid):
143
144
  await self.set_winner(None)
144
145
  else:
@@ -150,7 +151,7 @@ class TicTacToe(Game):
150
151
  async def on_timeout(self) -> None:
151
152
  self.turn += 1
152
153
  self.timeout = True
153
- await self.set_winner(self.current.member)
154
+ await self.set_winner(self.current)
154
155
  await self.update()
155
156
 
156
157
  def _is_winner(self, player: Player) -> bool:
easterobot/hunts/hunt.py CHANGED
@@ -17,6 +17,7 @@ from sqlalchemy import and_, func, select, update
17
17
  from sqlalchemy.ext.asyncio import AsyncSession
18
18
 
19
19
  from easterobot.bot import Easterobot
20
+ from easterobot.casino.roulette import RouletteManager
20
21
  from easterobot.config import (
21
22
  RAND,
22
23
  agree,
@@ -24,6 +25,7 @@ from easterobot.config import (
24
25
  from easterobot.hunts.luck import HuntLuck
25
26
  from easterobot.models import Egg, Hunt
26
27
  from easterobot.query import QueryManager
28
+ from easterobot.utils import in_seconds
27
29
 
28
30
  logger = logging.getLogger(__name__)
29
31
  DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
@@ -98,7 +100,7 @@ class HuntQuery(QueryManager):
98
100
  )
99
101
  return egg_max or 0
100
102
 
101
- async def get_eggs(
103
+ async def get_egg_count(
102
104
  self,
103
105
  session: AsyncSession,
104
106
  guild_id: int,
@@ -125,7 +127,7 @@ class HuntQuery(QueryManager):
125
127
  ) -> HuntLuck:
126
128
  """Get the luck of a member."""
127
129
  luck = 1.0
128
- egg_count = await self.get_eggs(session, guild_id, user_id)
130
+ egg_count = await self.get_egg_count(session, guild_id, user_id)
129
131
  if egg_count != 0:
130
132
  egg_max = await self.get_max_eggs(session, guild_id)
131
133
  if egg_max != 0:
@@ -147,6 +149,9 @@ class HuntCog(commands.Cog, HuntQuery):
147
149
  @commands.Cog.listener()
148
150
  async def on_ready(self) -> None:
149
151
  """Handle ready event, can be trigger many time if disconnected."""
152
+ # Wait main finish
153
+ await self.bot.init_finished.wait()
154
+
150
155
  # Unlock all eggs
151
156
  logger.info("Unlock all previous eggs")
152
157
  async with AsyncSession(self.bot.engine) as session:
@@ -155,6 +160,7 @@ class HuntCog(commands.Cog, HuntQuery):
155
160
 
156
161
  # Start hunt
157
162
  logger.info("Start hunt handler")
163
+ # TODO(dashstrom): rework this
158
164
  pending_hunts: set[asyncio.Task[Any]] = set()
159
165
  while True:
160
166
  if pending_hunts:
@@ -173,6 +179,7 @@ class HuntCog(commands.Cog, HuntQuery):
173
179
  description: str,
174
180
  *,
175
181
  member_id: Optional[int] = None,
182
+ casino: bool = False,
176
183
  send_method: Optional[
177
184
  Callable[..., Awaitable[discord.Message]]
178
185
  ] = None,
@@ -184,6 +191,14 @@ class HuntCog(commands.Cog, HuntQuery):
184
191
  return
185
192
  guild = channel.guild
186
193
 
194
+ # Random casino events
195
+ if casino:
196
+ casino_event = self.bot.config.casino.sample_event()
197
+ if casino_event:
198
+ manager = RouletteManager(self.bot)
199
+ await manager.run(channel)
200
+ return
201
+
187
202
  # Get from config
188
203
  action = self.bot.config.action.rand()
189
204
  emoji = self.bot.egg_emotes.rand()
@@ -200,6 +215,7 @@ class HuntCog(commands.Cog, HuntQuery):
200
215
  # Start hunt
201
216
  logger.info("Start hunt in %s", channel.jump_url)
202
217
  timeout = self.bot.config.hunt.timeout + 1
218
+ has_game = False
203
219
  view = discord.ui.View(timeout=timeout)
204
220
  button: discord.ui.Button[Any] = discord.ui.Button(
205
221
  label=label,
@@ -253,14 +269,11 @@ class HuntCog(commands.Cog, HuntQuery):
253
269
  # Set the button callback
254
270
  button.callback = button_callback # type: ignore[method-assign]
255
271
 
256
- # Set next hunt
257
- next_hunt = time.time() + timeout
258
-
259
272
  # Create and embed
260
273
  emb = embed(
261
274
  title="Un œuf a été découvert !",
262
275
  description=description
263
- + f"\n\nTirage du vainqueur : <t:{next_hunt:.0f}:R>",
276
+ + f"\n\n-# Tirage du vainqueur {in_seconds(timeout)}",
264
277
  thumbnail=emoji.url,
265
278
  )
266
279
 
@@ -269,18 +282,18 @@ class HuntCog(commands.Cog, HuntQuery):
269
282
  message = await channel.send(embed=emb, view=view)
270
283
  else:
271
284
  message = await send_method(embed=emb, view=view)
285
+ message = await message.channel.fetch_message(message.id)
272
286
 
273
287
  # TODO(dashstrom): channel is wrong due to the send message !
274
288
  # TODO(dashstrom): Why wait if timeout ???
275
289
  # Wait the end of the hunt
276
290
  message_url = f"{channel.jump_url}/{message.id}"
277
- async with channel.typing():
278
- try:
279
- await asyncio.wait_for(
280
- view.wait(), timeout=self.bot.config.hunt.timeout
281
- )
282
- except asyncio.TimeoutError:
283
- logger.info("End hunt for %s", message_url)
291
+ try:
292
+ await asyncio.wait_for(
293
+ view.wait(), timeout=self.bot.config.hunt.timeout
294
+ )
295
+ except asyncio.TimeoutError:
296
+ logger.info("End hunt for %s", message_url)
284
297
 
285
298
  # Disable button and view after hunt
286
299
  button.disabled = True
@@ -315,6 +328,7 @@ class HuntCog(commands.Cog, HuntQuery):
315
328
 
316
329
  if RAND.random() < self.bot.config.hunt.game:
317
330
  # Update button
331
+ has_game = True
318
332
  button.label = "Duel en cours ..."
319
333
  button.style = discord.ButtonStyle.gray
320
334
 
@@ -329,7 +343,7 @@ class HuntCog(commands.Cog, HuntQuery):
329
343
  if duel_winner is None:
330
344
  winner = None
331
345
  loser = None
332
- elif duel_winner == loser:
346
+ elif duel_winner.member == loser:
333
347
  winner, loser = loser, winner
334
348
 
335
349
  if winner:
@@ -358,7 +372,11 @@ class HuntCog(commands.Cog, HuntQuery):
358
372
  )
359
373
  emb = embed(
360
374
  title=text,
361
- description=action.fail.text(loser),
375
+ description=(
376
+ action.fail.text(loser)
377
+ + "\n\n-# Ce message disparaîtra "
378
+ + in_seconds(300)
379
+ ),
362
380
  image=action.fail.gif,
363
381
  )
364
382
  await channel.send(
@@ -368,8 +386,17 @@ class HuntCog(commands.Cog, HuntQuery):
368
386
  )
369
387
 
370
388
  if winner:
371
- # Send embed for the winner
372
- winner_eggs = eggs.get(winner.id, 0) + 1
389
+ # Get egg count (or the new counter if game)
390
+ if has_game:
391
+ winner_eggs = await self.get_egg_count(
392
+ session,
393
+ winner.guild.id,
394
+ winner.id,
395
+ )
396
+ else:
397
+ winner_eggs = eggs.get(winner.id, 0) + 1
398
+
399
+ # Send the winner embed winner
373
400
  emb = embed(
374
401
  title=f"{winner.display_name} récupère un œuf",
375
402
  description=action.success.text(winner),
@@ -488,7 +515,11 @@ class HuntCog(commands.Cog, HuntQuery):
488
515
  try:
489
516
  await asyncio.gather(
490
517
  *[
491
- self.start_hunt(hunt_id, self.bot.config.appear.rand())
518
+ self.start_hunt(
519
+ hunt_id,
520
+ self.bot.config.appear.rand(),
521
+ casino=True,
522
+ )
492
523
  for hunt_id in hunt_ids
493
524
  ]
494
525
  )
easterobot/hunts/luck.py CHANGED
@@ -18,6 +18,8 @@ class HuntLuck:
18
18
  @property
19
19
  def discovered(self) -> float:
20
20
  """Discovered probability."""
21
+ if self.egg_count <= self.config.commands.search.discovered.shield:
22
+ return 1.0
21
23
  prob = self.config.commands.search.discovered.probability(self.luck)
22
24
  if self.sleep_hours:
23
25
  prob /= self.config.sleep.divide_discovered
@@ -26,6 +28,8 @@ class HuntLuck:
26
28
  @property
27
29
  def spotted(self) -> float:
28
30
  """Spotted probability."""
31
+ if self.egg_count <= self.config.commands.search.spotted.shield:
32
+ return 0.0
29
33
  prob = self.config.commands.search.spotted.probability(self.luck)
30
34
  if self.sleep_hours:
31
35
  prob /= self.config.sleep.divide_spotted
@@ -33,21 +37,22 @@ class HuntLuck:
33
37
 
34
38
  def sample_discovered(self) -> bool:
35
39
  """Get if player get detected."""
36
- if self.egg_count <= self.config.commands.search.discovered.shield:
37
- logger.info("discovered: shield with %s eggs", self.egg_count)
38
- return True
39
40
  sample = RAND.random()
41
+ discovered = self.discovered
40
42
  logger.info(
41
- "discovered: expect over %.2f got %.2f",
42
- self.discovered,
43
+ "discovered: expect over %.4f got %.4f",
44
+ discovered,
43
45
  sample,
44
46
  )
45
- return self.discovered > sample
47
+ return discovered > sample
46
48
 
47
49
  def sample_spotted(self) -> bool:
48
50
  """Get if player get spotted."""
49
- if self.egg_count <= self.config.commands.search.spotted.shield:
50
- logger.info("spotted: shield with %s eggs", self.egg_count)
51
- return True
52
51
  sample = RAND.random()
53
- return self.spotted < sample
52
+ spotted = self.spotted
53
+ logger.info(
54
+ "spotted: expect over %.4f got %.4f",
55
+ spotted,
56
+ sample,
57
+ )
58
+ return spotted > sample
easterobot/hunts/rank.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  from dataclasses import dataclass
4
4
 
5
- from sqlalchemy import func, select
5
+ from sqlalchemy import and_, func, not_, select
6
6
  from sqlalchemy.ext.asyncio import AsyncSession
7
7
 
8
8
  from easterobot.config import agree
@@ -51,6 +51,23 @@ class Ranking:
51
51
  return []
52
52
  return self.hunters[limit * n : limit * (n + 1)]
53
53
 
54
+ def count_page(self, n: int) -> int:
55
+ """Count the number of page.
56
+
57
+ Example:
58
+ >>> Ranking([Hunter(1, 1, 3), Hunter(2, 2, 2)]).count_page(10)
59
+ 1
60
+ >>> Ranking([]).count_page(10)
61
+ 0
62
+ >>> Ranking([Hunter(i, i, 20 - i) for i in range(10)]).count_page(10)
63
+ 1
64
+ >>> Ranking([Hunter(i, i, 20 - i) for i in range(11)]).count_page(10)
65
+ 2
66
+ """
67
+ if not self.hunters:
68
+ return 0
69
+ return (len(self.hunters) - 1) // n + 1
70
+
54
71
  def get(self, member_id: int) -> Hunter:
55
72
  """Get a hunter."""
56
73
  for hunter in self.hunters:
@@ -62,15 +79,20 @@ class Ranking:
62
79
  async def from_guild(
63
80
  session: AsyncSession,
64
81
  guild_id: int,
82
+ *,
83
+ unlock_only: bool = False,
65
84
  ) -> "Ranking":
66
85
  """Get ranks by page."""
86
+ where = Egg.guild_id == guild_id
87
+ if unlock_only:
88
+ where = and_(where, not_(Egg.lock))
67
89
  query = (
68
90
  select(
69
91
  Egg.user_id,
70
92
  func.rank().over(order_by=func.count().desc()).label("row"),
71
93
  func.count().label("count"),
72
94
  )
73
- .where(Egg.guild_id == guild_id)
95
+ .where(where)
74
96
  .group_by(Egg.user_id)
75
97
  .order_by(func.count().desc())
76
98
  )
easterobot/locker.py ADDED
@@ -0,0 +1,180 @@
1
+ """Lock the module."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import AsyncIterator, Iterable
6
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
7
+ from types import TracebackType
8
+ from typing import ClassVar, Optional, final
9
+
10
+ import discord
11
+ from sqlalchemy import and_, func, not_, select
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+ from typing_extensions import override
14
+
15
+ from easterobot.config import agree
16
+ from easterobot.models import Egg
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ async def fetch_unlocked_eggs(
22
+ session: AsyncSession,
23
+ guild_id: int,
24
+ user_id: int,
25
+ counter: int,
26
+ ) -> list[Egg]:
27
+ """Get the count of unlocked eggs."""
28
+ return list(
29
+ (
30
+ await session.scalars(
31
+ select(Egg)
32
+ .where(
33
+ and_(
34
+ Egg.guild_id == guild_id,
35
+ Egg.user_id == user_id,
36
+ not_(Egg.lock),
37
+ )
38
+ )
39
+ .order_by(func.random()) # Randomize
40
+ .limit(counter)
41
+ )
42
+ ).all()
43
+ )
44
+
45
+
46
+ async def fetch_unlocked_egg_count(
47
+ session: AsyncSession,
48
+ guild_id: int,
49
+ user_ids: Iterable[int],
50
+ ) -> dict[int, int]:
51
+ """Get the count of unlocked eggs."""
52
+ user_ids = list(user_ids)
53
+ res = await session.execute(
54
+ select(Egg.user_id, func.count().label("count"))
55
+ .where(
56
+ and_(
57
+ Egg.guild_id == guild_id,
58
+ Egg.user_id.in_(user_ids),
59
+ not_(Egg.lock),
60
+ )
61
+ )
62
+ .group_by(Egg.user_id)
63
+ )
64
+ result = dict(res.all()) # type: ignore[arg-type]
65
+ for user_id in user_ids:
66
+ if user_id not in result:
67
+ result[user_id] = 0
68
+ return result
69
+
70
+
71
+ class EggLockerError(Exception):
72
+ pass
73
+
74
+
75
+ @final
76
+ class EggLocker(AbstractAsyncContextManager["EggLocker"]):
77
+ # TODO(dashstrom): memory leak over time
78
+ _guild_locks: ClassVar[dict[int, asyncio.Lock]] = {}
79
+
80
+ def __init__(self, session: AsyncSession, guild_id: int) -> None:
81
+ """Init EggLocker."""
82
+ self._session = session
83
+ self._guild_id = guild_id
84
+ self._eggs: list[Egg] = []
85
+
86
+ @asynccontextmanager
87
+ async def transaction(self) -> AsyncIterator[None]:
88
+ """Return guild lock."""
89
+ if self._guild_id not in self._guild_locks:
90
+ self._guild_locks[self._guild_id] = asyncio.Lock()
91
+ async with self._guild_locks[self._guild_id]:
92
+ yield
93
+ await self._session.commit()
94
+
95
+ async def get(
96
+ self,
97
+ member: discord.Member,
98
+ egg_count: int,
99
+ ) -> list[Egg]:
100
+ """Update the egg locker."""
101
+ eggs = await fetch_unlocked_eggs(
102
+ self._session,
103
+ self._guild_id,
104
+ member.id,
105
+ egg_count,
106
+ )
107
+ if len(eggs) < egg_count:
108
+ egg_text = agree("œuf", "œufs", len(eggs))
109
+ error_message = (
110
+ f"{member.mention} n'a plus que {len(eggs)} {egg_text} "
111
+ f"disponible sur les {egg_count} demandés"
112
+ )
113
+ raise EggLockerError(error_message)
114
+ for egg in eggs:
115
+ egg.lock = True
116
+ self._eggs.extend(eggs)
117
+ logger.info(
118
+ "Lock %s egg(s) of %s (%s)",
119
+ len(eggs),
120
+ member.name,
121
+ member.id,
122
+ )
123
+ return eggs
124
+
125
+ async def delete(self, eggs: Iterable[Egg]) -> None:
126
+ """Delete eggs."""
127
+ futures = []
128
+ for egg in eggs:
129
+ self._eggs.remove(egg)
130
+ futures.append(self._session.delete(egg))
131
+ await asyncio.gather(*futures)
132
+
133
+ def update(self, eggs: Iterable[Egg]) -> None:
134
+ """Update eggs."""
135
+ for egg in eggs:
136
+ self._eggs.append(egg)
137
+ self._session.add(egg)
138
+
139
+ async def pre_check(
140
+ self,
141
+ members: dict[discord.Member, int],
142
+ ) -> None:
143
+ """Return the list of invalid user."""
144
+ if not members:
145
+ return
146
+ member_ids = {member.id: member for member in members}
147
+ counter = await fetch_unlocked_egg_count(
148
+ self._session,
149
+ self._guild_id,
150
+ member_ids,
151
+ )
152
+ for user_id, egg_count in counter.items():
153
+ member = member_ids[user_id]
154
+ required = members[member]
155
+ if egg_count < required:
156
+ egg_text = agree("œuf", "œufs", egg_count)
157
+ error_message = (
158
+ f"{member.mention} n'a que {egg_count} {egg_text} "
159
+ f"disponible sur les {required} demandés"
160
+ )
161
+ raise EggLockerError(error_message)
162
+
163
+ @override
164
+ async def __aenter__(self) -> "EggLocker":
165
+ """Return `self` upon entering the runtime context."""
166
+ return self
167
+
168
+ @override
169
+ async def __aexit__(
170
+ self,
171
+ exc_type: Optional[type[BaseException]],
172
+ exc_value: Optional[BaseException],
173
+ traceback: Optional[TracebackType],
174
+ ) -> Optional[bool]:
175
+ """Raise any exception triggered within the runtime context."""
176
+ async with self.transaction():
177
+ await self._session.rollback()
178
+ for egg in self._eggs:
179
+ egg.lock = False
180
+ return None
easterobot/models.py CHANGED
@@ -37,6 +37,15 @@ class Egg(Base):
37
37
  guild_id = self.guild_id or "@me"
38
38
  return f"{DISCORD_URL}/{guild_id}/{self.channel_id}/{self.id}"
39
39
 
40
+ def duplicate(self) -> "Egg":
41
+ """Duplicate the egg."""
42
+ return Egg(
43
+ guild_id=self.guild_id,
44
+ channel_id=self.channel_id,
45
+ user_id=self.user_id,
46
+ emoji_id=self.emoji_id,
47
+ )
48
+
40
49
 
41
50
  class Hunt(Base):
42
51
  __tablename__ = "hunt"
@@ -11,11 +11,15 @@ logs:
11
11
  database: sqlite+aiosqlite://%(data)s/easterobot.db
12
12
  group: egg
13
13
  sleep:
14
- start: '23:00:00'
15
- end: '09:00:00'
14
+ start: '01:00:00'
15
+ end: '10:00:00'
16
16
  divide_hunt: 2
17
17
  divide_discovered: 2
18
18
  divide_spotted: 1.5
19
+ casino:
20
+ probability: 0.05
21
+ roulette:
22
+ duration: 300
19
23
  hunt:
20
24
  timeout: 300.0
21
25
  cooldown:
@@ -45,6 +49,8 @@ commands:
45
49
  cooldown: 120.0
46
50
  tictactoe:
47
51
  cooldown: 120.0
52
+ skyjo:
53
+ cooldown: 120.0
48
54
  info:
49
55
  cooldown: 30.0
50
56
  basket:
@@ -321,35 +327,35 @@ action:
321
327
  gif: https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExMTh2ZjFrdDdpN2h3aXZtYWxtcmdmNGI2ZWoyczg5b3d4Y2c0N2pwbiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/3otPoxbuCuvXTbICfm/giphy.gif
322
328
  fail:
323
329
  text: "On a connu des morts de hamster plus originales, la peur de perdre l'œuf l’a emporté."
324
- gif: https://tenor.com/view/hamster-ayasan-gif-24417559
330
+ gif: https://c.tenor.com/9t7q9vCcYmMAAAAd/tenor.gif
325
331
  - text: "Pinguiner pour l'œuf"
326
332
  success:
327
333
  text: "I'm fast as fuck, boy. {Iel} court en s'enfuyant avec l'œuf."
328
334
  gif: https://media0.giphy.com/media/v1.Y2lkPTc5MGI3NjExYXBrcmQwemt3Y2x1NXdrcnV0aWZxcWZrMGtzYXV4NTBrZGFhYnFhOSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/y5xRFqfdkgEXS/giphy.gif
329
335
  fail:
330
336
  text: "Saboté par un autre pingouin. {Iel} prend une petite baignade."
331
- gif: https://tenor.com/view/penguin-slap-gif-5263949288532448516
337
+ gif: https://c.tenor.com/SQ1N_QKllQQAAAAd/tenor.gif
332
338
  - text: "Invoquer un chat pour l'œuf"
333
339
  success:
334
- text: "Oh waw, quel maniement félin de l'œuf !"
335
- gif: https://tenor.com/view/happy-easter-images2022-cute-cat-gif-25406467
340
+ text: Oh waw, quel maniement félin de l'œuf !
341
+ gif: https://c.tenor.com/vwfpPXNTDPEAAAAd/tenor.gif
336
342
  fail:
337
343
  text: "{Iel} n'est pas tombé{e} sur le chat le plus adroit du quartier, {iel} tombe fatalement."
338
- gif: https://tenor.com/view/cat-meme-fall-down-sink-counter-gif-2237760511603157439
339
- - text: "Boire l'œuf ?"
344
+ gif: https://c.tenor.com/Hw4fVEKAqb8AAAAd/tenor.gif
345
+ - text: Boire l'œuf ?
340
346
  success:
341
347
  text: "Effectivement, quand ils sont solides, ça a plus de chances d'être des œufs. {Iel} récupère un œuf."
342
- gif: https://tenor.com/view/sml-junior-oh-man-guys-i-found-so-many-eggs-finding-eggs-gif-25432207
348
+ gif: https://c.tenor.com/cqBCSM5N55UAAAAd/tenor.gif
343
349
  fail:
344
350
  text: "Mmhh, effectivement, un œuf, ça ne se boit pas. Sa vision double l'empêche d'attraper l'œuf."
345
351
  gif: https://gifdb.com/images/high/easter-bunny-bud-light-6y0qi1qackuq0ijb.webp
346
- - text: "Poler pour l'oeuf"
352
+ - text: "Poler pour l'œuf"
347
353
  success:
348
354
  text: "Quelle adresse, {iel} séduit le lapin de Pâques et obtient un œuf"
349
- gif: https://tenor.com/view/easter-happyeaster-eastersunday-easterbunny-lol-gif-4003716
355
+ gif: https://c.tenor.com/aThWCOLIhvwAAAAd/tenor.gif
350
356
  fail:
351
357
  text: "Oulà, je n'aurais pas aimé être à sa place."
352
- gif: https://tenor.com/view/pole-whoops-fall-oh-no-face-plant-gif-14246225
358
+ gif: https://c.tenor.com/WQV-9F8Wt7QAAAAd/tenor.gif
353
359
  - text: "Ramasser calmement l'œuf"
354
360
  success:
355
361
  text: "Sa sagesse lui a permis d'arriver en vie jusqu'à l'œuf."
@@ -3,3 +3,5 @@ icons/arrow.png https://www.flaticon.com/free-icon/right-arrow_10570067
3
3
  icons/end.png https://www.flaticon.com/free-icon/end_5553850
4
4
  icons/versus.png https://www.flaticon.com/free-icon/versus_7960356
5
5
  icons/wait.png https://www.flaticon.com/free-icon/hourglass_3874060
6
+ skyjo/ https://github.com/Dashstrom/easterobot
7
+ placements/ https://github.com/Dashstrom/easterobot