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
easterobot/bot.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """Main program."""
2
2
 
3
+ import asyncio
3
4
  import logging
4
5
  import pathlib
5
6
  import shutil
@@ -17,6 +18,7 @@ import discord.app_commands
17
18
  import discord.ext.commands
18
19
  from alembic.command import upgrade
19
20
  from sqlalchemy.ext.asyncio import create_async_engine
21
+ from typing_extensions import override
20
22
 
21
23
  if TYPE_CHECKING:
22
24
  from easterobot.games.game import GameCog
@@ -44,6 +46,7 @@ class Easterobot(discord.ext.commands.Bot):
44
46
  owner: discord.User
45
47
  game: "GameCog"
46
48
  hunt: "HuntCog"
49
+ init_finished: asyncio.Event
47
50
 
48
51
  def __init__(self, config: MConfig) -> None:
49
52
  """Initialise Easterbot."""
@@ -167,6 +170,12 @@ class Easterobot(discord.ext.commands.Bot):
167
170
  """Run the bot with the given token."""
168
171
  self.run(token=self.config.verified_token())
169
172
 
173
+ @override
174
+ async def start(self, token: str, *, reconnect: bool = True) -> None:
175
+ """Add event for starting."""
176
+ self.init_finished = asyncio.Event()
177
+ await super().start(token=token, reconnect=reconnect)
178
+
170
179
  async def on_ready(self) -> None:
171
180
  """Handle ready event, can be trigger many time if disconnected."""
172
181
  # Sync bot commands
@@ -196,6 +205,7 @@ class Easterobot(discord.ext.commands.Bot):
196
205
  self.user,
197
206
  getattr(self.user, "id", "unknown"),
198
207
  )
208
+ self.init_finished.set()
199
209
 
200
210
  async def _load_emojis(self) -> None:
201
211
  emojis = {
@@ -203,6 +213,8 @@ class Easterobot(discord.ext.commands.Bot):
203
213
  for emoji in await self.fetch_application_emojis()
204
214
  }
205
215
  emotes_path = (self.config.resources / "emotes").resolve()
216
+ # TODO(dashstrom): remove old one !
217
+ # TODO(dashstrom): cache emoji synced !
206
218
  self.app_emojis = {}
207
219
  for emote in emotes_path.glob("**/*"):
208
220
  if not emote.is_file():
@@ -221,4 +233,5 @@ class Easterobot(discord.ext.commands.Bot):
221
233
  self.app_emojis[name] = emoji
222
234
  else:
223
235
  logger.info("Load emoji %s", name)
224
- self.app_emojis[name] = emojis[name]
236
+ emoji = emojis[name]
237
+ self.app_emojis[name] = emoji
@@ -0,0 +1 @@
1
+ """Module for casino events."""
@@ -0,0 +1,269 @@
1
+ """Module to play roulette."""
2
+
3
+ import asyncio
4
+ from asyncio import sleep
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Union
7
+
8
+ import discord
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from easterobot.bot import Easterobot
12
+ from easterobot.config import RAND, agree
13
+ from easterobot.locker import EggLocker
14
+ from easterobot.utils import in_seconds
15
+
16
+ if TYPE_CHECKING:
17
+ from easterobot.models import Egg
18
+
19
+
20
+ @dataclass(frozen=True, order=True)
21
+ class Play:
22
+ name: str
23
+ emoji: str
24
+ bet: int
25
+ payout: int
26
+ slots: frozenset[int]
27
+
28
+ @property
29
+ def label(self) -> str:
30
+ """Returns the label of the bet."""
31
+ return agree(
32
+ f"{self.bet} œuf sur {self.name}",
33
+ f"{self.bet} œufs sur {self.name}",
34
+ self.bet,
35
+ )
36
+
37
+ @property
38
+ def probability(self) -> float:
39
+ """Returns the winning probability."""
40
+ return len(self.slots) / 37
41
+
42
+ @property
43
+ def eggs(self) -> float:
44
+ """Returns the number of eggs won."""
45
+ return self.payout * self.bet
46
+
47
+
48
+ # fmt: off
49
+ plays = [
50
+ Play("noir", "⚫", 1, 2, frozenset({2, 4, 6, 8, 10, 11, 13, 15, 17, 20,
51
+ 22, 24, 26, 28, 29, 31, 33, 35})),
52
+ Play("rouge", "🔴", 1, 2, frozenset({1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21,
53
+ 23, 25, 27, 30, 32, 34, 36})),
54
+ Play("impaire", "1️⃣", 3, 2, frozenset(range(1, 37, 2))),
55
+ Play("pair", "2️⃣", 3, 2, frozenset(range(2, 37, 2))),
56
+ Play("manque", "⬅️", 5, 2, frozenset(range(1, 19))),
57
+ Play("passe", "➡️", 5, 2, frozenset(range(19, 37))),
58
+ Play("zero", "0️⃣", 1, 36, frozenset({0})),
59
+ ]
60
+ play_mapper = {p.label: p for p in plays}
61
+ # fmt: on
62
+
63
+
64
+ @dataclass
65
+ class RouletteResult:
66
+ draw: int
67
+ winners: dict[discord.Member, Play]
68
+ losers: dict[discord.Member, Play]
69
+
70
+ @property
71
+ def label(self) -> str:
72
+ """Returns the name(s) of the winning bet(s)."""
73
+ winning_plays = sorted(set(self.winners.values()))
74
+ if len(winning_plays) == 1:
75
+ return winning_plays[0].name
76
+ if winning_plays:
77
+ last = winning_plays[-1]
78
+ return (
79
+ ", ".join(p.name for p in winning_plays[:-1])
80
+ + " et "
81
+ + last.name
82
+ )
83
+ return "rien au numéro"
84
+
85
+
86
+ class Roulette:
87
+ def __init__(self, locker: EggLocker) -> None:
88
+ """Initialize an empty bet tracker."""
89
+ self.bets: dict[discord.Member, Play] = {}
90
+ self.eggs: dict[discord.Member, list[Egg]] = {}
91
+ self.locker = locker
92
+
93
+ async def bet(self, member: discord.Member, play: Play) -> None:
94
+ """Register a bet from a member."""
95
+ if member in self.eggs:
96
+ raise ValueError
97
+ async with self.locker.transaction():
98
+ eggs = await self.locker.get(member, play.bet)
99
+ self.eggs[member] = eggs
100
+ self.bets[member] = play
101
+
102
+ async def sample(self) -> "RouletteResult":
103
+ """Draw a number and determine winners/losers."""
104
+ ball = RAND.randint(0, 36)
105
+ losers = {}
106
+ winners = {}
107
+ futures = []
108
+ async with self.locker.transaction():
109
+ for member, play in self.bets.items():
110
+ eggs = self.eggs[member]
111
+ if ball in play.slots:
112
+ added_eggs = [
113
+ egg.duplicate()
114
+ for egg in eggs
115
+ for _ in range(play.payout - 1)
116
+ ]
117
+ self.locker.update(added_eggs)
118
+ winners[member] = play
119
+ else:
120
+ futures.append(self.locker.delete(eggs))
121
+ losers[member] = play
122
+ await asyncio.gather(*futures)
123
+ return RouletteResult(
124
+ draw=ball,
125
+ losers=losers,
126
+ winners=winners,
127
+ )
128
+
129
+
130
+ class BetView(discord.ui.View):
131
+ def __init__(self, embed: discord.Embed, roulette: Roulette) -> None:
132
+ """Create an interactive view for placing bets."""
133
+ super().__init__()
134
+ self.embed = embed
135
+ self.roulette = roulette
136
+ self.already_interact: set[discord.Member] = set()
137
+
138
+ def disable(self) -> None:
139
+ """Disable the selection UI."""
140
+ self.select_bet.disabled = True # type: ignore[attr-defined]
141
+ self.stop()
142
+
143
+ @discord.ui.select(
144
+ placeholder="Parier",
145
+ options=[
146
+ discord.SelectOption(
147
+ label=f"Parier {play.label}",
148
+ emoji=play.emoji,
149
+ value=play.label,
150
+ description=(
151
+ f"{play.probability:.2%} de repartir avec {play.eggs} œufs"
152
+ ),
153
+ )
154
+ for play in plays
155
+ ],
156
+ )
157
+ async def select_bet(
158
+ self,
159
+ interaction: discord.Interaction["Easterobot"],
160
+ select: discord.ui.Select["BetView"],
161
+ ) -> None:
162
+ """Handle the player's bet selection."""
163
+ user = interaction.user
164
+ if not isinstance(user, discord.Member) or interaction.message is None:
165
+ await interaction.response.defer()
166
+ return
167
+ if user in self.already_interact:
168
+ await interaction.response.send_message(
169
+ "Vous avez déjà choisi votre pari !",
170
+ ephemeral=True,
171
+ )
172
+ return
173
+ self.already_interact.add(user)
174
+ bet = play_mapper[select.values[0]]
175
+ await self.roulette.bet(user, bet)
176
+ embeds = interaction.message.embeds
177
+ assert self.embed.description is not None # noqa: S101
178
+ self.embed.description += (
179
+ f"\n> {interaction.user.mention} a parié {bet.label} {bet.emoji}"
180
+ )
181
+ await interaction.response.edit_message(embeds=[embeds[0], self.embed])
182
+
183
+
184
+ class RouletteManager:
185
+ def __init__(self, bot: Easterobot) -> None:
186
+ """Main manager for roulette game logic."""
187
+ self.bot = bot
188
+
189
+ async def run(
190
+ self,
191
+ source: Union[discord.Message, discord.TextChannel],
192
+ ) -> None:
193
+ """Run a full roulette session."""
194
+ guild = source.guild
195
+ if guild is None:
196
+ raise ValueError
197
+ async with (
198
+ AsyncSession(
199
+ self.bot.engine,
200
+ expire_on_commit=False,
201
+ ) as session,
202
+ EggLocker(session, guild.id) as locker,
203
+ ):
204
+ timeout = self.bot.config.casino.roulette.duration + 40
205
+ roulette = Roulette(locker)
206
+ embed = discord.Embed(
207
+ description=(
208
+ "# Roulette lapinique"
209
+ "\nLe Casino vous ouvre exceptionnellement ses portes. "
210
+ "Devant vous se trouve un élégant croupier lapin. "
211
+ "Il vous fixe droit dans les yeux "
212
+ "et prononce de simples mots en langue lapinique. "
213
+ "Magiquement, vous semblez comprendre : 'Faites vos jeux'."
214
+ "\n\n-# Faites attention, "
215
+ f"il annoncera sans doute la fin {in_seconds(timeout)}."
216
+ ),
217
+ color=0x00FF00,
218
+ )
219
+ text = discord.Embed(
220
+ description="# Annonces du croupier\n> Faites vos jeux",
221
+ color=0x00FF00,
222
+ )
223
+ assert text.description is not None # noqa: S101
224
+ embed.set_image(
225
+ url="https://i.pinimg.com/originals/32/37/bf/3237bf1e172a6089e0c437ffd3b28010.gif"
226
+ )
227
+ view = BetView(text, roulette)
228
+ if isinstance(source, discord.Message):
229
+ message = source
230
+ await message.edit(
231
+ embeds=[embed, text],
232
+ content="",
233
+ view=view,
234
+ )
235
+ else:
236
+ message = await source.send(
237
+ embeds=[embed, text],
238
+ view=view,
239
+ )
240
+ await sleep(timeout)
241
+ text.description += "\n> Les jeux sont faits"
242
+ await message.edit(embeds=[embed, text])
243
+ await sleep(20)
244
+ view.disable()
245
+ text.description += "\n> Rien ne va plus"
246
+ await message.edit(view=view, embeds=[embed, text])
247
+ await sleep(20)
248
+ result = await roulette.sample()
249
+ text.description += "\n> La bille s'arrête "
250
+ number = f"{result.draw:2d}".replace(" ", "\xa0")
251
+ text.description += f"sur le ||{number}||"
252
+ text.description += f"\n> Le lapin annonce ||{result.label}||"
253
+ await message.edit(view=None, embeds=[embed, text])
254
+
255
+ messages = []
256
+ for member, bet in result.winners.items():
257
+ egg_text = agree("œuf", "œufs", bet.bet)
258
+ messages.append(
259
+ f"{member.mention} repart avec {bet.eggs} {egg_text}"
260
+ )
261
+ for member, bet in result.losers.items():
262
+ egg_text = agree("œuf", "œufs", bet.bet)
263
+ messages.append(f"{member.mention} perd {bet.bet} {egg_text}")
264
+ if messages:
265
+ await sleep(5)
266
+ await message.reply( # type: ignore[call-overload]
267
+ content="\n".join(messages),
268
+ view=None,
269
+ )
@@ -14,6 +14,7 @@ from easterobot.commands.game import (
14
14
  from easterobot.commands.help import help_command
15
15
  from easterobot.commands.info import info_command
16
16
  from easterobot.commands.reset import reset_command
17
+ from easterobot.commands.roulette import roulette_command
17
18
  from easterobot.commands.search import search_command
18
19
  from easterobot.commands.top import top_command
19
20
 
@@ -28,6 +29,7 @@ __all__ = [
28
29
  "info_command",
29
30
  "reset_command",
30
31
  "rockpaperscissor_command",
32
+ "roulette_command",
31
33
  "search_command",
32
34
  "tictactoe_command",
33
35
  "top_command",
@@ -1,152 +1,115 @@
1
1
  """Module for disable hunt."""
2
2
 
3
3
  import asyncio
4
- from typing import Callable, Optional
4
+ from typing import Optional
5
5
 
6
6
  import discord
7
7
  from discord import app_commands
8
- from sqlalchemy import and_, func, not_, select
9
8
  from sqlalchemy.ext.asyncio import AsyncSession
10
9
 
11
10
  from easterobot.config import RAND
12
- from easterobot.games.connect import Connect4
11
+ from easterobot.games.connect4 import Connect4
13
12
  from easterobot.games.game import Game
14
13
  from easterobot.games.rock_paper_scissor import RockPaperScissor
14
+ from easterobot.games.skyjo import Skyjo
15
15
  from easterobot.games.tic_tac_toe import TicTacToe
16
16
  from easterobot.hunts.rank import Ranking
17
- from easterobot.models import Egg
17
+ from easterobot.locker import EggLocker, EggLockerError
18
18
 
19
19
  from .base import Context, controlled_command, egg_command_group
20
20
 
21
- lock = asyncio.Lock()
22
-
23
-
24
- async def get_unlocked_eggs(
25
- session: AsyncSession, member: discord.Member, 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 == member.guild.id,
35
- Egg.user_id == member.id,
36
- not_(Egg.lock),
37
- )
38
- )
39
- .order_by(func.random()) # Randomize
40
- .limit(counter)
41
- )
42
- ).all()
43
- )
44
-
45
21
 
46
- async def game_dual( # noqa: C901, D103, PLR0912
22
+ async def random_members(
47
23
  ctx: Context,
48
- member: Optional[discord.Member],
49
24
  bet: int,
50
- cls: Callable[[discord.Member, discord.Member, discord.Message], Game],
51
- ) -> None:
25
+ ) -> list[discord.Member]:
26
+ """Random members."""
52
27
  # If no member choose a random play in the guild with enough egg
53
- if member is None:
54
- if bet == 0:
55
- members = [m for m in ctx.guild.members if m.id != ctx.user.id]
56
- else:
57
- # TODO(dashstrom): can chose member with locked eggs
58
- async with AsyncSession(ctx.client.engine) as session:
59
- ranking = await Ranking.from_guild(session, ctx.guild_id)
60
- hunters = ranking.over(bet)
61
- mapper_member = {m.id: m for m in ctx.guild.members}
62
- members = [
63
- mapper_member[h.member_id]
64
- for h in hunters
65
- if h.member_id != ctx.user.id and h.member_id in mapper_member
66
- ]
67
- if members:
68
- member = RAND.choice(members)
69
- else:
70
- await ctx.response.send_message(
71
- "Aucun utilisateur trouvé !",
72
- ephemeral=True,
28
+ if bet == 0:
29
+ members = [
30
+ m for m in ctx.guild.members if m.id != ctx.user.id and not m.bot
31
+ ]
32
+ else:
33
+ # TODO(dashstrom): can chose member with locked eggs
34
+ async with AsyncSession(ctx.client.engine) as session:
35
+ ranking = await Ranking.from_guild(
36
+ session,
37
+ ctx.guild_id,
38
+ unlock_only=True,
73
39
  )
74
- return
75
-
76
- # Validate user
77
- if (member.bot or member == ctx.user) and not ctx.client.is_super_admin(
78
- member
79
- ):
40
+ hunters = ranking.over(bet)
41
+ mapper_member = {m.id: m for m in ctx.guild.members}
42
+ members = [
43
+ mapper_member[h.member_id]
44
+ for h in hunters
45
+ if h.member_id != ctx.user.id and h.member_id in mapper_member
46
+ ]
47
+ RAND.shuffle(members)
48
+ return members
49
+
50
+
51
+ async def game_dual( # noqa: D103
52
+ ctx: Context,
53
+ bet: int,
54
+ cls: type[Game],
55
+ *members: discord.Member,
56
+ ) -> None:
57
+ min_player = cls.minimum_player()
58
+ max_player = cls.maximum_player()
59
+ if min_player > len(members) + 1:
60
+ await ctx.response.send_message(
61
+ f"Vous devez être au minimum {min_player} joueurs",
62
+ ephemeral=True,
63
+ )
64
+ return
65
+ if max_player < len(members) + 1:
80
66
  await ctx.response.send_message(
81
- "L'utilisateur n'est pas valide !",
67
+ f"Vous devez être au maximum {min_player} joueurs",
82
68
  ephemeral=True,
83
69
  )
84
70
  return
85
71
 
86
72
  # Check if user has enough eggs for ask
87
- async with AsyncSession(ctx.client.engine) as session:
88
- e1, e2 = await asyncio.gather(
89
- get_unlocked_eggs(session, ctx.user, bet),
90
- get_unlocked_eggs(session, member, bet),
91
- )
92
- if len(e1) < bet:
93
- await ctx.response.send_message(
94
- "Vous n'avez pas assez d'oeufs",
95
- ephemeral=True,
96
- )
97
- return
98
- if len(e2) < bet:
99
- await ctx.response.send_message(
100
- f"{member.mention} n'a pas assez d'oeufs",
101
- ephemeral=True,
73
+ async with AsyncSession(
74
+ ctx.client.engine,
75
+ expire_on_commit=False,
76
+ ) as session:
77
+ locker = EggLocker(session, ctx.guild.id)
78
+ try:
79
+ await locker.pre_check(
80
+ {ctx.user: bet, **{m: bet for m in members}}
102
81
  )
82
+ except EggLockerError as err:
83
+ await ctx.response.send_message(str(err), ephemeral=True)
103
84
  return
104
85
 
105
- msg = await ctx.client.game.ask_dual(ctx, member, bet=bet)
106
- if msg:
107
- # Check if user still have enough eggs and lock them
108
- async with AsyncSession(ctx.client.engine) as session:
109
- async with lock:
110
- e1, e2 = await asyncio.gather(
111
- get_unlocked_eggs(session, ctx.user, bet),
112
- get_unlocked_eggs(session, member, bet),
113
- )
114
- if len(e1) < bet:
115
- await ctx.response.send_message(
116
- "Vous n'avez plus assez d'oeufs",
117
- ephemeral=True,
118
- )
119
- return
120
- for e in e1:
121
- e.lock = True
122
- if len(e2) < bet:
123
- await ctx.response.send_message(
124
- f"{member.mention} n'a plus assez d'oeufs",
125
- ephemeral=True,
126
- )
86
+ msg = await ctx.client.game.ask_dual(ctx, members, bet=bet)
87
+ if msg:
88
+ # Unlock all egg at end
89
+ async with locker:
90
+ # Lock the egg of player
91
+ try:
92
+ async with locker.transaction():
93
+ all_eggs = await asyncio.gather(
94
+ locker.get(ctx.user, bet),
95
+ *[locker.get(m, bet) for m in members],
96
+ )
97
+ except EggLockerError as err:
98
+ await msg.reply(str(err), delete_after=30)
127
99
  return
128
- for e in e2:
129
- e.lock = True
130
- await session.commit()
131
100
 
132
- # Play the game
133
- winner = None
134
- try:
135
- game = cls(ctx.user, member, msg)
101
+ players = [ctx.user, *members]
102
+ RAND.shuffle(players)
103
+ game = cls(ctx.client, msg, *players)
136
104
  await ctx.client.game.run(game)
137
105
  winner = await game.wait_winner()
138
- finally:
139
- # Give eggs to the winner or remove previous one
140
- async with lock:
141
- for e in e1:
142
- e.lock = False
143
- if winner:
144
- e.user_id = winner.id
145
- for e in e2:
146
- e.lock = False
147
- if winner:
148
- e.user_id = winner.id
149
- await session.commit()
106
+ if winner:
107
+ for eggs in all_eggs:
108
+ for egg in eggs:
109
+ egg.user_id = winner.member.id
110
+
111
+ # Send change
112
+ await session.commit()
150
113
 
151
114
 
152
115
  @egg_command_group.command(
@@ -160,7 +123,10 @@ async def connect4_command(
160
123
  bet: app_commands.Range[int, 0] = 0,
161
124
  ) -> None:
162
125
  """Run a Connect4."""
163
- await game_dual(ctx, member, bet, Connect4)
126
+ members = (
127
+ (await random_members(ctx, bet))[:1] if member is None else [member]
128
+ )
129
+ await game_dual(ctx, bet, Connect4, *members)
164
130
 
165
131
 
166
132
  @egg_command_group.command(
@@ -174,7 +140,10 @@ async def tictactoe_command(
174
140
  bet: app_commands.Range[int, 0] = 0,
175
141
  ) -> None:
176
142
  """Run a tictactoe."""
177
- await game_dual(ctx, member, bet, TicTacToe)
143
+ members = (
144
+ (await random_members(ctx, bet))[:1] if member is None else [member]
145
+ )
146
+ await game_dual(ctx, bet, TicTacToe, *members)
178
147
 
179
148
 
180
149
  @egg_command_group.command(
@@ -188,4 +157,44 @@ async def rockpaperscissor_command(
188
157
  bet: app_commands.Range[int, 0] = 0,
189
158
  ) -> None:
190
159
  """Run a rockpaperscissor."""
191
- await game_dual(ctx, member, bet, RockPaperScissor)
160
+ members = (
161
+ (await random_members(ctx, bet))[:1] if member is None else [member]
162
+ )
163
+ await game_dual(ctx, bet, RockPaperScissor, *members)
164
+
165
+
166
+ @egg_command_group.command(
167
+ name="skyjo",
168
+ description="Lancer une partie de Skyjo",
169
+ )
170
+ @controlled_command(cooldown=True, channel_permissions={"send_messages": True})
171
+ async def skyjo_command( # noqa: PLR0913
172
+ ctx: Context,
173
+ member1: Optional[discord.Member] = None,
174
+ member2: Optional[discord.Member] = None,
175
+ member3: Optional[discord.Member] = None,
176
+ member4: Optional[discord.Member] = None,
177
+ member5: Optional[discord.Member] = None,
178
+ member6: Optional[discord.Member] = None,
179
+ member7: Optional[discord.Member] = None,
180
+ bet: app_commands.Range[int, 0] = 0,
181
+ ) -> None:
182
+ """Run a skyjo."""
183
+ members = [
184
+ m
185
+ for m in (
186
+ member1,
187
+ member2,
188
+ member3,
189
+ member4,
190
+ member5,
191
+ member6,
192
+ member7,
193
+ )
194
+ if m
195
+ ]
196
+ if not members:
197
+ player_count = RAND.randint(1, 8)
198
+ rand_members = await random_members(ctx, bet)
199
+ members = rand_members[:player_count]
200
+ await game_dual(ctx, bet, Skyjo, *members)
@@ -51,9 +51,9 @@ async def info_command(
51
51
  title=f"Informations sur {hunter.display_name}",
52
52
  description=(
53
53
  f"Classement : {hunter_rank.badge}\n"
54
- f"Nombre d'oeufs : `{hunter_rank.eggs}`\n"
54
+ f"Nombre d'œufs : `{hunter_rank.eggs}`\n"
55
55
  f"Chance brute : `{member_luck.luck:.0%}`\n"
56
- f"Chance de trouver un oeuf : `{member_luck.discovered:.0%}`\n"
56
+ f"Chance de trouver un œuf : `{member_luck.discovered:.0%}`\n"
57
57
  f"Chance de se faire voler : `{member_luck.spotted:.0%}`"
58
58
  ),
59
59
  ),