|
|
@@ -1,8 +1,10 @@
|
|
1
|
1
|
import math
|
|
2
|
2
|
import random
|
|
|
3
|
+from time import perf_counter
|
|
3
|
4
|
|
|
4
|
|
-from discord import Interaction
|
|
5
|
|
-from discord.app_commands import command
|
|
|
5
|
+from discord import Interaction, UnfurledMediaItem
|
|
|
6
|
+from discord.app_commands import command, Range, guild_only
|
|
|
7
|
+from discord.ui import LayoutView, Section, TextDisplay, Thumbnail, Container
|
|
6
|
8
|
|
|
7
|
9
|
from rocketbot.bot import Rocketbot
|
|
8
|
10
|
from rocketbot.cogs.basecog import BaseCog
|
|
|
@@ -18,26 +20,66 @@ class GamesCog(BaseCog, name="Games"):
|
|
18
|
20
|
)
|
|
19
|
21
|
|
|
20
|
22
|
@command(
|
|
|
23
|
+ description='Rolls a die and gives the result. Provides seconds of amusement.'
|
|
|
24
|
+ )
|
|
|
25
|
+ @guild_only
|
|
|
26
|
+ async def roll(self, interaction: Interaction, sides: Range[int, 2, 100], share: bool = False):
|
|
|
27
|
+ """
|
|
|
28
|
+ Rolls a die.
|
|
|
29
|
+
|
|
|
30
|
+ Parameters
|
|
|
31
|
+ ----------
|
|
|
32
|
+ sides : Range[int, 2, 100]
|
|
|
33
|
+ how many sides on the die
|
|
|
34
|
+ share : bool
|
|
|
35
|
+ whether to show the result to everyone in chat, otherwise only shown to you
|
|
|
36
|
+ """
|
|
|
37
|
+ result = random.randint(1, sides)
|
|
|
38
|
+ who = interaction.user.mention if share else 'You'
|
|
|
39
|
+ text = f'## :game_die: D{sides} rolled\n'\
|
|
|
40
|
+ '\n'\
|
|
|
41
|
+ f' {who} rolled a **{result}**.'
|
|
|
42
|
+ if result == 69:
|
|
|
43
|
+ text += ' :smirk:'
|
|
|
44
|
+ if sides == 20 and result == 1:
|
|
|
45
|
+ text += ' :slight_frown:'
|
|
|
46
|
+ if sides == 20 and result == 20:
|
|
|
47
|
+ text += ' :tada:'
|
|
|
48
|
+
|
|
|
49
|
+ # This is a lot of work for a dumb joke
|
|
|
50
|
+ others = set()
|
|
|
51
|
+ for _ in range(min(sides - 1, 3)):
|
|
|
52
|
+ r = random.randint(1, sides)
|
|
|
53
|
+ while r in others or r == result:
|
|
|
54
|
+ r = random.randint(1, sides)
|
|
|
55
|
+ others.add(r)
|
|
|
56
|
+ text += f'\n\n-# Users who rolled {result} also liked {listed_items(list(others))}'
|
|
|
57
|
+
|
|
|
58
|
+ await interaction.response.send_message(text, ephemeral=not share)
|
|
|
59
|
+
|
|
|
60
|
+ @command(
|
|
21
|
61
|
description='Creates a really terrible Minesweeper puzzle.'
|
|
22
|
62
|
)
|
|
|
63
|
+ @guild_only
|
|
23
|
64
|
async def minesweeper(self, interaction: Interaction):
|
|
24
|
|
- # Generate grid with randomly placed mines
|
|
25
|
|
- width = 8
|
|
26
|
|
- height = 8
|
|
27
|
|
- mine_count = math.ceil(width * height * 0.15)
|
|
28
|
|
- grid = [[0 for _ in range(width)] for _ in range(height)]
|
|
29
|
|
- for _ in range(mine_count):
|
|
30
|
|
- while True:
|
|
31
|
|
- x, y = random.randint(0, width - 1), random.randint(0, height - 1)
|
|
32
|
|
- if grid[x][y] == 0:
|
|
33
|
|
- grid[x][y] = -1
|
|
34
|
|
- break
|
|
|
65
|
+ MINE = -1
|
|
|
66
|
+ BLANK = 0
|
|
|
67
|
+
|
|
|
68
|
+ # Generate grid
|
|
|
69
|
+ width, height = 8, 8 # about as big as you can get. 10x10 has too many spoiler blocks and Discord doesn't parse them.
|
|
|
70
|
+ mine_count = 10
|
|
|
71
|
+ grid = [[BLANK for _ in range(width)] for _ in range(height)]
|
|
|
72
|
+ all_positions = [(x, y) for y in range(height) for x in range(width)]
|
|
|
73
|
+
|
|
|
74
|
+ # Place mines randomly
|
|
|
75
|
+ random.shuffle(all_positions)
|
|
|
76
|
+ for x, y in all_positions[:mine_count]:
|
|
|
77
|
+ grid[x][y] = MINE
|
|
35
|
78
|
|
|
36
|
79
|
# Update free spaces with mine counts
|
|
37
|
80
|
for y in range(height):
|
|
38
|
81
|
for x in range(width):
|
|
39
|
|
- if grid[x][y] < 0:
|
|
40
|
|
- # Mine
|
|
|
82
|
+ if grid[x][y] == MINE:
|
|
41
|
83
|
continue
|
|
42
|
84
|
nearby_mine_count = 0
|
|
43
|
85
|
for y0 in range(y - 1, y + 2):
|
|
|
@@ -46,17 +88,19 @@ class GamesCog(BaseCog, name="Games"):
|
|
46
|
88
|
nearby_mine_count += 1
|
|
47
|
89
|
grid[x][y] = nearby_mine_count
|
|
48
|
90
|
|
|
49
|
|
- # Pick a non-mine starting tile to reveal
|
|
50
|
|
- start_x = 0
|
|
51
|
|
- start_y = 0
|
|
52
|
|
- while True:
|
|
53
|
|
- x, y = math.floor(random.gauss(width / 2, width / 4)), math.floor(random.gauss(height / 2, height / 4))
|
|
54
|
|
- if 0 <= x < width and 0 <= y < height and grid[x][y] >= 0:
|
|
55
|
|
- start_x, start_y = x, y
|
|
|
91
|
+ # Pick a non-mine starting tile to reveal (favoring 0s)
|
|
|
92
|
+ start_x, start_y = 0, 0
|
|
|
93
|
+ for n in range(8):
|
|
|
94
|
+ start_positions = [ pt for pt in all_positions if BLANK <= grid[pt[0]][pt[1]] <= n ]
|
|
|
95
|
+ if len(start_positions) > 0:
|
|
|
96
|
+ # sort by closeness to center
|
|
|
97
|
+ start_positions.sort(key=lambda pt: math.fabs(pt[0] - width / 2) + math.fabs(pt[1] - height / 2))
|
|
|
98
|
+ # pick a random one from the top 10
|
|
|
99
|
+ start_x, start_y = random.choice(start_positions[:10])
|
|
56
|
100
|
break
|
|
57
|
101
|
|
|
58
|
102
|
# Render
|
|
59
|
|
- text = ''
|
|
|
103
|
+ puzzle = ''
|
|
60
|
104
|
symbols = [
|
|
61
|
105
|
'\u274C', # cross mark (red X)
|
|
62
|
106
|
'0\uFE0F\u20E3', # 0 + variation selector 16 + combining enclosing keycap
|
|
|
@@ -70,22 +114,45 @@ class GamesCog(BaseCog, name="Games"):
|
|
70
|
114
|
'8\uFE0F\u20E3',
|
|
71
|
115
|
]
|
|
72
|
116
|
for y in range(height):
|
|
73
|
|
- text += ' '
|
|
|
117
|
+ puzzle += ' '
|
|
74
|
118
|
for x in range(width):
|
|
75
|
119
|
is_revealed = x == start_x and y == start_y
|
|
76
|
120
|
if not is_revealed:
|
|
77
|
|
- text += '||'
|
|
|
121
|
+ puzzle += '||'
|
|
78
|
122
|
val = grid[x][y]
|
|
79
|
|
- text += symbols[val + 1]
|
|
|
123
|
+ puzzle += symbols[val + 1]
|
|
80
|
124
|
if not is_revealed:
|
|
81
|
|
- text += '||'
|
|
82
|
|
- text += ' '
|
|
83
|
|
- text += '\n'
|
|
|
125
|
+ puzzle += '||'
|
|
|
126
|
+ puzzle += ' '
|
|
|
127
|
+ puzzle += '\n'
|
|
84
|
128
|
|
|
|
129
|
+ text = "## Minesweeper (kinda)\n"\
|
|
|
130
|
+ f"Here's a really terrible randomized Minesweeper puzzle. Sorry. I did my best.¹\n"\
|
|
|
131
|
+ "\n"\
|
|
|
132
|
+ f"Uncover everything except the {mine_count} :x: mines. There's no game logic or anything. Just a markdown parlor trick. Police yourselves.\n"\
|
|
|
133
|
+ "\n"\
|
|
|
134
|
+ f"{puzzle}"\
|
|
|
135
|
+ "\n"\
|
|
|
136
|
+ "-# ¹ I didn't really do my best."
|
|
85
|
137
|
await interaction.response.send_message(
|
|
86
|
|
- f"Here's a really terrible randomized Minesweeper puzzle. Sorry. I did my best.¹\n"
|
|
87
|
|
- f"Uncover everything except the {mine_count} :x: mines.\n\n"
|
|
88
|
|
- f"{text}\n"
|
|
89
|
|
- "-# ¹ I mean, not really.",
|
|
|
138
|
+ view=_MinesweeperLayout(text),
|
|
90
|
139
|
ephemeral=True,
|
|
91
|
140
|
)
|
|
|
141
|
+
|
|
|
142
|
+class _MinesweeperLayout(LayoutView):
|
|
|
143
|
+ def __init__(self, text_content: str):
|
|
|
144
|
+ super().__init__()
|
|
|
145
|
+ text = TextDisplay(text_content)
|
|
|
146
|
+ thumb = Thumbnail(UnfurledMediaItem('https://static.rksp.net/rocketbot/games/minesweeper/icon.png'), description='Minesweeper icon')
|
|
|
147
|
+ section = Section(text, accessory=thumb)
|
|
|
148
|
+ container = Container(section, accent_color=0xff0000)
|
|
|
149
|
+ self.add_item(container)
|
|
|
150
|
+
|
|
|
151
|
+def listed_items(items: list) -> str:
|
|
|
152
|
+ if len(items) == 0:
|
|
|
153
|
+ return 'nothing'
|
|
|
154
|
+ if len(items) == 1:
|
|
|
155
|
+ return f'{items[0]}'
|
|
|
156
|
+ if len(items) == 2:
|
|
|
157
|
+ return f'{items[0]} and {items[1]}'
|
|
|
158
|
+ return (', '.join([ str(i) for i in items[:-1]])) + f', and {items[-1]}'
|