easterobot 1.0.0__py3-none-any.whl → 1.1.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.
- easterobot/alembic/env.py +91 -0
- easterobot/alembic/script.py.mako +28 -0
- easterobot/alembic/versions/2f0d4305e320_init_database.py +67 -0
- easterobot/alembic/versions/940c3b9c702d_add_lock_on_eggs.py +38 -0
- easterobot/bot.py +93 -462
- easterobot/cli.py +56 -17
- easterobot/commands/__init__.py +8 -0
- easterobot/commands/base.py +34 -13
- easterobot/commands/basket.py +10 -12
- easterobot/commands/edit.py +4 -4
- easterobot/commands/enable.py +5 -1
- easterobot/commands/game.py +187 -0
- easterobot/commands/help.py +1 -1
- easterobot/commands/reset.py +1 -1
- easterobot/commands/search.py +4 -4
- easterobot/commands/top.py +7 -18
- easterobot/config.py +67 -3
- easterobot/games/__init__.py +14 -0
- easterobot/games/connect.py +206 -0
- easterobot/games/game.py +262 -0
- easterobot/games/rock_paper_scissor.py +206 -0
- easterobot/games/tic_tac_toe.py +168 -0
- easterobot/hunts/__init__.py +14 -0
- easterobot/hunts/hunt.py +428 -0
- easterobot/hunts/rank.py +82 -0
- easterobot/models.py +2 -1
- easterobot/resources/alembic.ini +87 -0
- easterobot/resources/config.example.yml +10 -2
- easterobot/resources/credits.txt +5 -1
- easterobot/resources/emotes/icons/arrow.png +0 -0
- easterobot/resources/emotes/icons/end.png +0 -0
- easterobot/resources/emotes/icons/versus.png +0 -0
- easterobot/resources/emotes/icons/wait.png +0 -0
- {easterobot-1.0.0.dist-info → easterobot-1.1.1.dist-info}/METADATA +11 -5
- easterobot-1.1.1.dist-info/RECORD +66 -0
- easterobot-1.0.0.dist-info/RECORD +0 -48
- /easterobot/resources/{eggs → emotes/eggs}/egg_01.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_02.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_03.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_04.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_05.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_06.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_07.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_08.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_09.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_10.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_11.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_12.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_13.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_14.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_15.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_16.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_17.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_18.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_19.png +0 -0
- /easterobot/resources/{eggs → emotes/eggs}/egg_20.png +0 -0
- {easterobot-1.0.0.dist-info → easterobot-1.1.1.dist-info}/WHEEL +0 -0
- {easterobot-1.0.0.dist-info → easterobot-1.1.1.dist-info}/entry_points.txt +0 -0
- {easterobot-1.0.0.dist-info → easterobot-1.1.1.dist-info}/licenses/LICENSE +0 -0
easterobot/config.py
CHANGED
@@ -1,9 +1,12 @@
|
|
1
1
|
"""Main program."""
|
2
2
|
|
3
|
+
import logging
|
4
|
+
import logging.config
|
3
5
|
import os
|
4
6
|
import pathlib
|
5
7
|
import random
|
6
8
|
from abc import ABC, abstractmethod
|
9
|
+
from argparse import Namespace
|
7
10
|
from collections.abc import Iterable
|
8
11
|
from typing import (
|
9
12
|
Any,
|
@@ -17,6 +20,7 @@ from typing import (
|
|
17
20
|
|
18
21
|
import discord
|
19
22
|
import msgspec
|
23
|
+
from alembic.config import Config
|
20
24
|
from typing_extensions import TypeGuard, get_args, get_origin, override
|
21
25
|
|
22
26
|
RAND = random.SystemRandom()
|
@@ -24,7 +28,11 @@ RAND = random.SystemRandom()
|
|
24
28
|
T = TypeVar("T")
|
25
29
|
V = TypeVar("V")
|
26
30
|
Members = Union[discord.Member, list[discord.Member]]
|
31
|
+
|
32
|
+
HERE = pathlib.Path(__file__).parent.resolve()
|
27
33
|
RESOURCES = pathlib.Path(__file__).parent.resolve() / "resources"
|
34
|
+
DEFAULT_CONFIG_PATH = pathlib.Path("config.yml")
|
35
|
+
EXAMPLE_CONFIG_PATH = RESOURCES / "config.example.yml"
|
28
36
|
|
29
37
|
|
30
38
|
class Serializable(ABC, Generic[V]):
|
@@ -167,12 +175,14 @@ class MCooldown(msgspec.Struct):
|
|
167
175
|
class MWeights(msgspec.Struct):
|
168
176
|
egg: float
|
169
177
|
speed: float
|
178
|
+
base: float
|
170
179
|
|
171
180
|
|
172
181
|
class MHunt(msgspec.Struct):
|
173
182
|
timeout: float
|
174
183
|
cooldown: MCooldown
|
175
184
|
weights: MWeights
|
185
|
+
game: float
|
176
186
|
|
177
187
|
|
178
188
|
class MCommand(msgspec.Struct):
|
@@ -219,7 +229,7 @@ class MText(msgspec.Struct):
|
|
219
229
|
Conjugation = dict[str, MGender]
|
220
230
|
|
221
231
|
|
222
|
-
class MCommands(msgspec.Struct):
|
232
|
+
class MCommands(msgspec.Struct, forbid_unknown_fields=True):
|
223
233
|
search: SearchCommand
|
224
234
|
top: MCommand
|
225
235
|
basket: MCommand
|
@@ -228,6 +238,9 @@ class MCommands(msgspec.Struct):
|
|
228
238
|
disable: MCommand
|
229
239
|
help: MCommand
|
230
240
|
edit: MCommand
|
241
|
+
connect4: MCommand
|
242
|
+
tictactoe: MCommand
|
243
|
+
rockpaperscissor: MCommand
|
231
244
|
|
232
245
|
def __getitem__(self, key: str, /) -> MCommand:
|
233
246
|
"""Get a command."""
|
@@ -256,13 +269,20 @@ class MConfig(msgspec.Struct, dict=True):
|
|
256
269
|
appear: RandomItem[str]
|
257
270
|
action: RandomItem[MText]
|
258
271
|
commands: MCommands
|
272
|
+
token: Optional[Union[str, msgspec.UnsetType]] = msgspec.UNSET
|
259
273
|
_resources: Optional[Union[pathlib.Path, msgspec.UnsetType]] = (
|
260
274
|
msgspec.field(name="resources", default=msgspec.UNSET)
|
261
275
|
)
|
262
276
|
_working_directory: Optional[Union[pathlib.Path, msgspec.UnsetType]] = (
|
263
277
|
msgspec.field(name="working_directory", default=msgspec.UNSET)
|
264
278
|
)
|
265
|
-
|
279
|
+
|
280
|
+
@property
|
281
|
+
def database_uri(self) -> str:
|
282
|
+
"""Get async string for database."""
|
283
|
+
return self.database.replace(
|
284
|
+
"%(data)s", "/" + self.working_directory.as_posix()
|
285
|
+
)
|
266
286
|
|
267
287
|
def verified_token(self) -> str:
|
268
288
|
"""Get the safe token."""
|
@@ -320,6 +340,36 @@ class MConfig(msgspec.Struct, dict=True):
|
|
320
340
|
conj.attach(self.conjugation)
|
321
341
|
return conj(member)
|
322
342
|
|
343
|
+
def alembic_config(self, namespace: Optional[Namespace] = None) -> Config:
|
344
|
+
"""Get alembic config."""
|
345
|
+
config_alembic = str(self.resources / "alembic.ini")
|
346
|
+
cfg = Config(
|
347
|
+
file_=config_alembic,
|
348
|
+
ini_section="alembic" if namespace is None else namespace.name,
|
349
|
+
cmd_opts=namespace,
|
350
|
+
attributes={
|
351
|
+
"easterobot_config": self,
|
352
|
+
},
|
353
|
+
)
|
354
|
+
cfg.set_main_option("sqlalchemy.url", self.database_uri)
|
355
|
+
cfg.set_main_option("script_location", str(HERE / "alembic"))
|
356
|
+
return cfg
|
357
|
+
|
358
|
+
def configure_logging(self) -> None:
|
359
|
+
"""Configure logging."""
|
360
|
+
if self.use_logging_file and not hasattr(self, "__logging_flag"):
|
361
|
+
logging_file = self.resources / "logging.conf"
|
362
|
+
defaults = {"data": self.working_directory.as_posix()}
|
363
|
+
if not logging_file.is_file():
|
364
|
+
error_message = f"Cannot find message: {str(logging_file)!r}"
|
365
|
+
raise FileNotFoundError(error_message)
|
366
|
+
logging.config.fileConfig(
|
367
|
+
logging_file,
|
368
|
+
disable_existing_loggers=False,
|
369
|
+
defaults=defaults,
|
370
|
+
)
|
371
|
+
self.__logging_flag = True
|
372
|
+
|
323
373
|
|
324
374
|
def _dec_hook(typ: type[T], obj: Any) -> T:
|
325
375
|
# Get the base type
|
@@ -371,7 +421,7 @@ def convert(obj: Any, typ: type[T]) -> T:
|
|
371
421
|
)
|
372
422
|
|
373
423
|
|
374
|
-
def
|
424
|
+
def load_config_from_buffer(
|
375
425
|
data: Union[bytes, str],
|
376
426
|
token: Optional[str] = None,
|
377
427
|
*,
|
@@ -388,6 +438,20 @@ def load_config(
|
|
388
438
|
return config
|
389
439
|
|
390
440
|
|
441
|
+
def load_config_from_path(
|
442
|
+
path: Union[str, pathlib.Path],
|
443
|
+
token: Optional[str] = None,
|
444
|
+
*,
|
445
|
+
env: bool = False,
|
446
|
+
) -> MConfig:
|
447
|
+
"""Load config."""
|
448
|
+
path = pathlib.Path(path)
|
449
|
+
data = path.read_bytes()
|
450
|
+
config = load_config_from_buffer(data, token=token, env=env)
|
451
|
+
config.attach_default_working_directory(path.parent)
|
452
|
+
return config
|
453
|
+
|
454
|
+
|
391
455
|
def agree(
|
392
456
|
singular: str,
|
393
457
|
plural: str,
|
@@ -0,0 +1,14 @@
|
|
1
|
+
"""Init package."""
|
2
|
+
|
3
|
+
from easterobot.bot import Easterobot
|
4
|
+
from easterobot.games.game import GameCog
|
5
|
+
|
6
|
+
__all__ = [
|
7
|
+
"GameCog",
|
8
|
+
]
|
9
|
+
|
10
|
+
|
11
|
+
async def setup(bot: Easterobot) -> None:
|
12
|
+
game_cog = GameCog(bot)
|
13
|
+
bot.game = game_cog
|
14
|
+
await bot.add_cog(game_cog)
|
@@ -0,0 +1,206 @@
|
|
1
|
+
"""Connect4 and Connect3."""
|
2
|
+
|
3
|
+
import datetime
|
4
|
+
from functools import partial
|
5
|
+
from typing import Optional
|
6
|
+
|
7
|
+
import discord
|
8
|
+
from discord.utils import format_dt
|
9
|
+
from typing_extensions import override
|
10
|
+
|
11
|
+
from easterobot.games.game import Game, Player
|
12
|
+
|
13
|
+
EMOJIS_MAPPER = {
|
14
|
+
"1️⃣": 0,
|
15
|
+
"2️⃣": 1,
|
16
|
+
"3️⃣": 2,
|
17
|
+
"4️⃣": 3,
|
18
|
+
"5️⃣": 4,
|
19
|
+
"6️⃣": 5,
|
20
|
+
"7️⃣": 6,
|
21
|
+
"8️⃣": 7,
|
22
|
+
"9️⃣": 8,
|
23
|
+
"🔟": 9,
|
24
|
+
}
|
25
|
+
EMOJIS = tuple(EMOJIS_MAPPER)
|
26
|
+
|
27
|
+
|
28
|
+
class Connect(Game):
|
29
|
+
def __init__( # noqa: PLR0913
|
30
|
+
self,
|
31
|
+
player1: discord.Member,
|
32
|
+
player2: discord.Member,
|
33
|
+
message: discord.Message,
|
34
|
+
rows: int,
|
35
|
+
cols: int,
|
36
|
+
win_count: int,
|
37
|
+
) -> None:
|
38
|
+
"""Instantiate Connect4."""
|
39
|
+
self.grid: list[list[Optional[Player]]] = [
|
40
|
+
[None] * rows for _ in range(cols)
|
41
|
+
]
|
42
|
+
self.timeout = False
|
43
|
+
self.player1 = Player(player1, 1)
|
44
|
+
self.player2 = Player(player2, 2)
|
45
|
+
self.rows = rows
|
46
|
+
self.cols = cols
|
47
|
+
self.win_count = win_count
|
48
|
+
self.turn = 0
|
49
|
+
super().__init__(message)
|
50
|
+
|
51
|
+
async def on_start(self) -> None:
|
52
|
+
"""Run."""
|
53
|
+
await self.update()
|
54
|
+
for emoji in EMOJIS[: self.cols]:
|
55
|
+
await self.message.add_reaction(emoji)
|
56
|
+
self.start_timer(60)
|
57
|
+
|
58
|
+
async def update(self) -> None:
|
59
|
+
"""Update the text."""
|
60
|
+
footer = ""
|
61
|
+
label = ""
|
62
|
+
if not self.terminate:
|
63
|
+
label = self.piece(self.current)
|
64
|
+
label += f" Joueur actuel : {self.current.member.mention}\n\n"
|
65
|
+
player: Optional[Player] = self.current
|
66
|
+
elif self.winner:
|
67
|
+
forfait = "par forfait " if self.timeout else ""
|
68
|
+
footer = f"\n## Gagnant {forfait}{self.winner.mention} 🎉"
|
69
|
+
player = self.current
|
70
|
+
else:
|
71
|
+
footer = (
|
72
|
+
f"\n## Égalité entre {self.player1.member.mention} "
|
73
|
+
f"et {self.player2.member.mention} 🤝"
|
74
|
+
)
|
75
|
+
player = None
|
76
|
+
content = label
|
77
|
+
content += "│".join(EMOJIS[: self.cols])
|
78
|
+
content += "\n"
|
79
|
+
content += "\n".join(
|
80
|
+
"│".join(self.piece(self.grid[y][x]) for y in range(self.cols))
|
81
|
+
for x in reversed(range(self.rows))
|
82
|
+
)
|
83
|
+
content += footer
|
84
|
+
now = datetime.datetime.now() + datetime.timedelta(seconds=62) # noqa: DTZ005
|
85
|
+
if not self.terminate:
|
86
|
+
content += f"\n\nFin du tour {format_dt(now, style='R')}"
|
87
|
+
embed = discord.Embed(description=content, color=self.color(player))
|
88
|
+
embed.set_author(
|
89
|
+
name="Partie terminée" if self.terminate else "Partie en cours",
|
90
|
+
icon_url=(
|
91
|
+
player.member.display_avatar.url
|
92
|
+
if player
|
93
|
+
else self.bot.app_emojis["end"].url
|
94
|
+
),
|
95
|
+
)
|
96
|
+
self.message = await self.message.edit(
|
97
|
+
embed=embed,
|
98
|
+
content="",
|
99
|
+
view=None,
|
100
|
+
)
|
101
|
+
|
102
|
+
async def on_reaction(
|
103
|
+
self,
|
104
|
+
member_id: int,
|
105
|
+
reaction: discord.PartialEmoji,
|
106
|
+
) -> None:
|
107
|
+
"""Add a reaction to the message."""
|
108
|
+
if (
|
109
|
+
reaction.name not in EMOJIS[: self.cols]
|
110
|
+
or member_id != self.current.member.id
|
111
|
+
):
|
112
|
+
return
|
113
|
+
await self.place(EMOJIS_MAPPER[reaction.name], self.current)
|
114
|
+
|
115
|
+
@property
|
116
|
+
def current(self) -> Player:
|
117
|
+
"""Get the current member playing."""
|
118
|
+
return [self.player1, self.player2][self.turn % 2]
|
119
|
+
|
120
|
+
def piece(self, member: Optional[Player]) -> str:
|
121
|
+
"""Get the current member playing."""
|
122
|
+
if member is None:
|
123
|
+
return "⚪"
|
124
|
+
if member == self.player1:
|
125
|
+
return "🔴"
|
126
|
+
if member == self.player2:
|
127
|
+
return "🟡"
|
128
|
+
error_message = f"Invalid member: {member!r}"
|
129
|
+
raise ValueError(error_message)
|
130
|
+
|
131
|
+
def color(self, player: Optional[Player]) -> Optional[discord.Colour]:
|
132
|
+
"""Get the current player playing."""
|
133
|
+
if player is None:
|
134
|
+
return discord.Colour.from_str("#d4d5d6") # Grey
|
135
|
+
if player == self.player1:
|
136
|
+
return discord.Colour.from_str("#ca2a3e") # Red
|
137
|
+
if player == self.player2:
|
138
|
+
return discord.Colour.from_str("#e9bb51") # Yellow
|
139
|
+
error_message = f"Invalid player: {player!r}"
|
140
|
+
raise ValueError(error_message)
|
141
|
+
|
142
|
+
async def place(
|
143
|
+
self,
|
144
|
+
col: int,
|
145
|
+
player: Player,
|
146
|
+
) -> None:
|
147
|
+
"""Place a jetton."""
|
148
|
+
async with self.lock:
|
149
|
+
if self.terminate:
|
150
|
+
return
|
151
|
+
winner = None
|
152
|
+
for row in range(self.rows):
|
153
|
+
if self.grid[col][row] is None:
|
154
|
+
await self.stop_timer()
|
155
|
+
self.grid[col][row] = player
|
156
|
+
if self._is_winner(col, row, player):
|
157
|
+
winner = player
|
158
|
+
break
|
159
|
+
else:
|
160
|
+
return # Can't be placed
|
161
|
+
if winner:
|
162
|
+
await self.set_winner(player.member)
|
163
|
+
elif all( # Draw case
|
164
|
+
self.grid[col][-1] is not None for col in range(self.cols)
|
165
|
+
):
|
166
|
+
await self.set_winner(None)
|
167
|
+
else:
|
168
|
+
self.turn += 1
|
169
|
+
self.start_timer(60)
|
170
|
+
await self.update()
|
171
|
+
|
172
|
+
@override
|
173
|
+
async def on_timeout(self) -> None:
|
174
|
+
self.turn += 1
|
175
|
+
self.timeout = True
|
176
|
+
await self.set_winner(self.current.member)
|
177
|
+
await self.update()
|
178
|
+
|
179
|
+
def _is_winner(self, col: int, row: int, player: Player) -> bool:
|
180
|
+
directions = [(1, 0), (0, 1), (1, 1), (1, -1)]
|
181
|
+
for dx, dy in directions:
|
182
|
+
if (
|
183
|
+
self._count_consecutive(col, row, dx, dy, player)
|
184
|
+
+ self._count_consecutive(col, row, -dx, -dy, player)
|
185
|
+
- 1
|
186
|
+
) >= self.win_count:
|
187
|
+
return True
|
188
|
+
return False
|
189
|
+
|
190
|
+
def _count_consecutive(
|
191
|
+
self, col: int, row: int, dx: int, dy: int, player: Player
|
192
|
+
) -> int:
|
193
|
+
count = 0
|
194
|
+
c, r = col, row
|
195
|
+
while (
|
196
|
+
0 <= c < self.cols
|
197
|
+
and 0 <= r < self.rows
|
198
|
+
and self.grid[c][r] == player
|
199
|
+
):
|
200
|
+
count += 1
|
201
|
+
c += dx
|
202
|
+
r += dy
|
203
|
+
return count
|
204
|
+
|
205
|
+
|
206
|
+
Connect4 = partial(Connect, rows=6, cols=7, win_count=4)
|
easterobot/games/game.py
ADDED
@@ -0,0 +1,262 @@
|
|
1
|
+
"""Base class for game."""
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import contextlib
|
5
|
+
import datetime
|
6
|
+
import logging
|
7
|
+
from collections.abc import Coroutine
|
8
|
+
from dataclasses import dataclass
|
9
|
+
from typing import Any, Callable, Optional
|
10
|
+
from uuid import uuid4
|
11
|
+
|
12
|
+
import discord
|
13
|
+
from discord.ext import commands
|
14
|
+
from discord.message import convert_emoji_reaction
|
15
|
+
from discord.utils import format_dt
|
16
|
+
|
17
|
+
from easterobot.bot import Easterobot
|
18
|
+
from easterobot.commands.base import Context, Interaction, InteractionChannel
|
19
|
+
from easterobot.config import RAND
|
20
|
+
|
21
|
+
logger = logging.getLogger(__name__)
|
22
|
+
AsyncCallback = Callable[[], Coroutine[Any, Any, None]]
|
23
|
+
Button = discord.ui.Button[discord.ui.View]
|
24
|
+
|
25
|
+
|
26
|
+
class GameError(Exception):
|
27
|
+
pass
|
28
|
+
|
29
|
+
|
30
|
+
class InvalidActionError(GameError):
|
31
|
+
pass
|
32
|
+
|
33
|
+
|
34
|
+
class InvalidPlayerError(GameError):
|
35
|
+
@staticmethod
|
36
|
+
def from_player(member: discord.Member) -> "InvalidPlayerError":
|
37
|
+
"""Create error from a member."""
|
38
|
+
return InvalidPlayerError(
|
39
|
+
f"Player {member!r} is not included in the game"
|
40
|
+
)
|
41
|
+
|
42
|
+
|
43
|
+
@dataclass
|
44
|
+
class Player:
|
45
|
+
member: discord.Member
|
46
|
+
number: int
|
47
|
+
|
48
|
+
|
49
|
+
class Game:
|
50
|
+
bot: Easterobot
|
51
|
+
|
52
|
+
def __init__(self, message: discord.Message) -> None:
|
53
|
+
"""Instantiate Game."""
|
54
|
+
self.id = uuid4()
|
55
|
+
self.message = message
|
56
|
+
self.terminate = False
|
57
|
+
self.winner: Optional[discord.Member] = None
|
58
|
+
self.lock = asyncio.Lock()
|
59
|
+
self._cleanup: Optional[AsyncCallback] = None
|
60
|
+
self._completion: Optional[AsyncCallback] = None
|
61
|
+
self._end_event = asyncio.Event()
|
62
|
+
self._reset_countdown_event = asyncio.Event()
|
63
|
+
self._timeout_task: Optional[asyncio.Task[None]] = None
|
64
|
+
|
65
|
+
async def set_completion(self, callback: AsyncCallback) -> None:
|
66
|
+
"""Get the current state for a player."""
|
67
|
+
self._completion = callback
|
68
|
+
|
69
|
+
async def wait_winner(self) -> Optional[discord.Member]:
|
70
|
+
"""Wait the end of the game."""
|
71
|
+
await self._end_event.wait()
|
72
|
+
return self.winner
|
73
|
+
|
74
|
+
async def on_start(self) -> None:
|
75
|
+
"""Get the current state for a player."""
|
76
|
+
|
77
|
+
async def on_reaction(
|
78
|
+
self,
|
79
|
+
member_id: int,
|
80
|
+
reaction: discord.PartialEmoji,
|
81
|
+
) -> None:
|
82
|
+
"""Add a reaction to the message."""
|
83
|
+
|
84
|
+
async def on_timeout(self) -> None:
|
85
|
+
"""Can when game timeout."""
|
86
|
+
|
87
|
+
async def set_winner(self, winner: Optional[discord.Member]) -> None:
|
88
|
+
"""Remove the game from the manager."""
|
89
|
+
self.terminate = True
|
90
|
+
self.winner = winner
|
91
|
+
if self._cleanup is not None:
|
92
|
+
await self._cleanup()
|
93
|
+
if self._completion is not None:
|
94
|
+
await self._completion()
|
95
|
+
self._end_event.set()
|
96
|
+
|
97
|
+
def start_timer(self, seconds: float) -> str:
|
98
|
+
"""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
|
103
|
+
|
104
|
+
async def stop_timer(self) -> None:
|
105
|
+
"""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()
|
111
|
+
|
112
|
+
async def _timeout_worker(self, seconds: float) -> None:
|
113
|
+
"""Timeout action."""
|
114
|
+
event = self._reset_countdown_event
|
115
|
+
try:
|
116
|
+
await asyncio.wait_for(event.wait(), timeout=seconds)
|
117
|
+
except asyncio.TimeoutError:
|
118
|
+
if not event.is_set():
|
119
|
+
async with self.lock:
|
120
|
+
await self.on_timeout()
|
121
|
+
|
122
|
+
def __repr__(self) -> str:
|
123
|
+
"""Get game representation."""
|
124
|
+
return (
|
125
|
+
f"<{self.__class__.__qualname__} "
|
126
|
+
f"id={self.id!r} message={self.message!r} "
|
127
|
+
f"terminate={self.terminate!r} winner={self.winner!r}"
|
128
|
+
">"
|
129
|
+
)
|
130
|
+
|
131
|
+
def __str__(self) -> str:
|
132
|
+
"""Get game representation."""
|
133
|
+
return Game.__repr__(self) # Enforce usage of Game class for __str__
|
134
|
+
|
135
|
+
|
136
|
+
class GameCog(commands.Cog):
|
137
|
+
def __init__(self, bot: Easterobot) -> None:
|
138
|
+
"""Manage all games."""
|
139
|
+
self.bot = bot
|
140
|
+
self._games: dict[int, Game] = {}
|
141
|
+
|
142
|
+
async def dual(
|
143
|
+
self,
|
144
|
+
channel: InteractionChannel,
|
145
|
+
reference: discord.Message,
|
146
|
+
user1: discord.Member,
|
147
|
+
user2: discord.Member,
|
148
|
+
) -> Optional[discord.Member]:
|
149
|
+
"""Start a dual between two players."""
|
150
|
+
from easterobot.games.connect import Connect4
|
151
|
+
from easterobot.games.rock_paper_scissor import RockPaperScissor
|
152
|
+
from easterobot.games.tic_tac_toe import TicTacToe
|
153
|
+
|
154
|
+
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
|
+
msg = await channel.send(
|
158
|
+
f"{user1.mention} et {user2.mention} vont s'affronter {dt} ...",
|
159
|
+
reference=reference,
|
160
|
+
)
|
161
|
+
await asyncio.sleep(63)
|
162
|
+
game: Game = cls(user1, user2, msg) # type: ignore[operator]
|
163
|
+
await self.run(game)
|
164
|
+
return await game.wait_winner()
|
165
|
+
|
166
|
+
async def run(self, game: Game) -> None:
|
167
|
+
"""Attach the game to the manager."""
|
168
|
+
message_id = game.message.id
|
169
|
+
|
170
|
+
async def _cleanup() -> None:
|
171
|
+
if message_id in self._games:
|
172
|
+
del self._games[message_id]
|
173
|
+
else:
|
174
|
+
logger.warning("Missing game: %s", game)
|
175
|
+
await game.message.clear_reactions()
|
176
|
+
|
177
|
+
self._games[message_id] = game
|
178
|
+
game._cleanup = _cleanup # noqa: SLF001
|
179
|
+
game.bot = self.bot
|
180
|
+
await game.on_start()
|
181
|
+
|
182
|
+
async def ask_dual(
|
183
|
+
self,
|
184
|
+
ctx: Context,
|
185
|
+
member: discord.Member,
|
186
|
+
bet: int,
|
187
|
+
) -> Optional[discord.Message]:
|
188
|
+
"""Send basic message for initialization."""
|
189
|
+
future: asyncio.Future[bool] = asyncio.Future()
|
190
|
+
accept = False
|
191
|
+
|
192
|
+
view = discord.ui.View()
|
193
|
+
yes_btn: Button = discord.ui.Button(
|
194
|
+
label="Accepter", style=discord.ButtonStyle.green, emoji="⚔️"
|
195
|
+
)
|
196
|
+
no_btn: Button = discord.ui.Button(
|
197
|
+
label="Refuser", style=discord.ButtonStyle.red, emoji="🛡️"
|
198
|
+
)
|
199
|
+
|
200
|
+
async def yes(interaction: Interaction) -> Any:
|
201
|
+
if interaction.user.id == member.id:
|
202
|
+
future.set_result(True)
|
203
|
+
await interaction.response.defer()
|
204
|
+
|
205
|
+
async def no(interaction: Interaction) -> Any:
|
206
|
+
if interaction.user.id == member.id:
|
207
|
+
future.set_result(False)
|
208
|
+
await interaction.response.defer()
|
209
|
+
|
210
|
+
yes_btn.callback = yes # type: ignore[method-assign,assignment]
|
211
|
+
no_btn.callback = no # type: ignore[method-assign,assignment]
|
212
|
+
view.add_item(yes_btn)
|
213
|
+
view.add_item(no_btn)
|
214
|
+
now = datetime.datetime.now() + datetime.timedelta(seconds=180) # noqa: DTZ005
|
215
|
+
dt = format_dt(now, style="R")
|
216
|
+
result = await ctx.response.send_message(
|
217
|
+
f"{member.mention}, {ctx.user.mention} "
|
218
|
+
f"vous demande en duel pour `{bet}` œufs ⚔️"
|
219
|
+
f"\nVous devez repondre {dt} !",
|
220
|
+
view=view,
|
221
|
+
)
|
222
|
+
message = result.resource
|
223
|
+
if not isinstance(message, discord.Message):
|
224
|
+
error_message = f"Invalid kind of message: {message!r}"
|
225
|
+
raise TypeError(error_message)
|
226
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
227
|
+
accept = await asyncio.wait_for(future, timeout=180)
|
228
|
+
if not accept:
|
229
|
+
await message.edit(
|
230
|
+
content=(
|
231
|
+
f"{member.mention}, {ctx.user.mention} a refusé le duel 🛡️"
|
232
|
+
),
|
233
|
+
view=None,
|
234
|
+
)
|
235
|
+
return None
|
236
|
+
if not isinstance(result.resource, discord.Message):
|
237
|
+
error_message = f"Invalid kind of message: {result.resource!r}"
|
238
|
+
raise TypeError(error_message)
|
239
|
+
return result.resource
|
240
|
+
|
241
|
+
@commands.Cog.listener()
|
242
|
+
async def on_raw_reaction_add(
|
243
|
+
self, payload: discord.RawReactionActionEvent
|
244
|
+
) -> None:
|
245
|
+
"""Handle reaction."""
|
246
|
+
if payload.message_author_id is None:
|
247
|
+
return
|
248
|
+
if self.bot.user and payload.user_id == self.bot.user.id:
|
249
|
+
return
|
250
|
+
if payload.message_id in self._games:
|
251
|
+
# Use connection for faster remove
|
252
|
+
emoji = convert_emoji_reaction(payload.emoji)
|
253
|
+
game = self._games[payload.message_id]
|
254
|
+
await asyncio.gather(
|
255
|
+
self.bot._connection.http.remove_reaction( # noqa: SLF001
|
256
|
+
payload.channel_id,
|
257
|
+
payload.message_id,
|
258
|
+
emoji,
|
259
|
+
payload.user_id,
|
260
|
+
),
|
261
|
+
game.on_reaction(payload.user_id, payload.emoji),
|
262
|
+
)
|