nonebot-plugin-werewolf 1.1.2__py3-none-any.whl → 1.1.5__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 (35) hide show
  1. nonebot_plugin_werewolf/__init__.py +9 -4
  2. nonebot_plugin_werewolf/config.py +11 -15
  3. nonebot_plugin_werewolf/constant.py +40 -3
  4. nonebot_plugin_werewolf/game.py +219 -181
  5. nonebot_plugin_werewolf/matchers/__init__.py +2 -0
  6. nonebot_plugin_werewolf/matchers/depends.py +38 -0
  7. nonebot_plugin_werewolf/matchers/message_in_game.py +25 -0
  8. nonebot_plugin_werewolf/matchers/poke/__init__.py +8 -0
  9. nonebot_plugin_werewolf/matchers/poke/chronocat_poke.py +117 -0
  10. nonebot_plugin_werewolf/matchers/poke/ob11_poke.py +80 -0
  11. nonebot_plugin_werewolf/matchers/start_game.py +202 -0
  12. nonebot_plugin_werewolf/player_set.py +37 -36
  13. nonebot_plugin_werewolf/players/__init__.py +10 -0
  14. nonebot_plugin_werewolf/players/can_shoot.py +53 -0
  15. nonebot_plugin_werewolf/players/civilian.py +7 -0
  16. nonebot_plugin_werewolf/players/guard.py +30 -0
  17. nonebot_plugin_werewolf/players/hunter.py +8 -0
  18. nonebot_plugin_werewolf/players/idiot.py +44 -0
  19. nonebot_plugin_werewolf/players/joker.py +21 -0
  20. nonebot_plugin_werewolf/players/player.py +233 -0
  21. nonebot_plugin_werewolf/players/prophet.py +22 -0
  22. nonebot_plugin_werewolf/players/werewolf.py +89 -0
  23. nonebot_plugin_werewolf/players/witch.py +66 -0
  24. nonebot_plugin_werewolf/players/wolfking.py +14 -0
  25. nonebot_plugin_werewolf/utils.py +58 -173
  26. {nonebot_plugin_werewolf-1.1.2.dist-info → nonebot_plugin_werewolf-1.1.5.dist-info}/METADATA +24 -4
  27. nonebot_plugin_werewolf-1.1.5.dist-info/RECORD +31 -0
  28. {nonebot_plugin_werewolf-1.1.2.dist-info → nonebot_plugin_werewolf-1.1.5.dist-info}/WHEEL +1 -1
  29. nonebot_plugin_werewolf/_timeout.py +0 -110
  30. nonebot_plugin_werewolf/matchers.py +0 -62
  31. nonebot_plugin_werewolf/ob11_ext.py +0 -72
  32. nonebot_plugin_werewolf/player.py +0 -462
  33. nonebot_plugin_werewolf-1.1.2.dist-info/RECORD +0 -16
  34. {nonebot_plugin_werewolf-1.1.2.dist-info → nonebot_plugin_werewolf-1.1.5.dist-info}/LICENSE +0 -0
  35. {nonebot_plugin_werewolf-1.1.2.dist-info → nonebot_plugin_werewolf-1.1.5.dist-info}/top_level.txt +0 -0
@@ -1,34 +1,36 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import contextlib
5
1
  import secrets
6
- from typing import TYPE_CHECKING, NoReturn
2
+ from typing import ClassVar, NoReturn
7
3
 
4
+ import anyio
5
+ from nonebot.adapters import Bot
8
6
  from nonebot.log import logger
7
+ from nonebot.utils import escape_tag
9
8
  from nonebot_plugin_alconna import At, Target, UniMessage
9
+ from nonebot_plugin_alconna.uniseg.message import Receipt
10
+ from nonebot_plugin_uninfo import Interface, SceneType
11
+ from typing_extensions import Self, assert_never
10
12
 
11
- from ._timeout import timeout
12
13
  from .config import config
13
- from .constant import GameState, GameStatus, KillReason, Role, RoleGroup, role_name_conv
14
+ from .constant import (
15
+ STOP_COMMAND,
16
+ STOP_COMMAND_PROMPT,
17
+ GameState,
18
+ GameStatus,
19
+ KillReason,
20
+ Role,
21
+ RoleGroup,
22
+ role_name_conv,
23
+ )
14
24
  from .exception import GameFinished
15
- from .player import Player
16
25
  from .player_set import PlayerSet
17
- from .utils import InputStore
18
-
19
- if TYPE_CHECKING:
20
- from nonebot.adapters import Bot
21
- from nonebot_plugin_alconna.uniseg.message import Receipt
26
+ from .players import Player
27
+ from .utils import InputStore, link
22
28
 
23
- starting_games: dict[str, dict[str, str]] = {}
24
- running_games: dict[str, Game] = {}
25
29
 
26
-
27
- def init_players(bot: Bot, game: Game, players: dict[str, str]) -> PlayerSet:
30
+ def init_players(bot: Bot, game: "Game", players: set[str]) -> PlayerSet:
28
31
  logger.opt(colors=True).debug(f"初始化 <c>{game.group.id}</c> 的玩家职业")
29
32
  role_preset = config.get_role_preset()
30
- preset = role_preset.get(len(players))
31
- if preset is None:
33
+ if (preset := role_preset.get(len(players))) is None:
32
34
  raise ValueError(
33
35
  f"玩家人数不符: "
34
36
  f"应为 {', '.join(map(str, role_preset))} 人, 传入{len(players)}人"
@@ -44,59 +46,77 @@ def init_players(bot: Bot, game: Game, players: dict[str, str]) -> PlayerSet:
44
46
  roles.remove(Role.Civilian)
45
47
  roles.append(Role.Joker)
46
48
 
47
- shuffled: list[Role] = []
48
- while roles:
49
- idx = secrets.randbelow(len(roles))
50
- shuffled.append(roles.pop(idx))
51
-
52
- logger.debug(f"职业分配: {shuffled}")
53
-
54
- async def selector(target_: Target, b: Bot) -> bool:
55
- return target_.self_id == bot.self_id and b is bot
56
-
57
- return PlayerSet(
58
- Player.new(
59
- role,
60
- bot,
61
- game,
62
- Target(
63
- user_id,
64
- private=True,
65
- self_id=bot.self_id,
66
- selector=selector,
67
- ),
68
- players[user_id],
69
- )
70
- for user_id, role in zip(players, shuffled, strict=True)
49
+ def _select_role() -> Role:
50
+ return roles.pop(secrets.randbelow(len(roles)))
51
+
52
+ player_set = PlayerSet(
53
+ Player.new(_select_role(), bot, game, user_id) for user_id in players
71
54
  )
55
+ logger.debug(f"职业分配完成: {player_set}")
56
+
57
+ return player_set
72
58
 
73
59
 
74
60
  class Game:
61
+ starting_games: ClassVar[dict[Target, dict[str, str]]] = {}
62
+ running_games: ClassVar[set[Self]] = set()
63
+
75
64
  bot: Bot
76
65
  group: Target
77
66
  players: PlayerSet
78
- _player_map: dict[str, Player]
67
+ interface: Interface
79
68
  state: GameState
80
69
  killed_players: list[Player]
81
70
 
82
- def __init__(self, bot: Bot, group: Target, players: dict[str, str]) -> None:
71
+ def __init__(
72
+ self,
73
+ bot: Bot,
74
+ group: Target,
75
+ players: set[str],
76
+ interface: Interface,
77
+ ) -> None:
83
78
  self.bot = bot
84
79
  self.group = group
85
80
  self.players = init_players(bot, self, players)
86
- self._player_map = {p.user_id: p for p in self.players}
81
+ self.interface = interface
87
82
  self.state = GameState(0)
88
83
  self.killed_players = []
84
+ self._player_map = {p.user_id: p for p in self.players}
85
+ self._scene = None
86
+
87
+ async def _fetch_group_scene(self) -> None:
88
+ scene = await self.interface.get_scene(SceneType.GROUP, self.group.id)
89
+ if scene is None:
90
+ scene = await self.interface.get_scene(SceneType.GUILD, self.group.id)
91
+
92
+ self._scene = scene
93
+
94
+ @property
95
+ def colored_name(self) -> str:
96
+ name = escape_tag(self.group.id)
97
+
98
+ if self._scene is None or self._scene.name is None:
99
+ name = f"<b><e>{name}</e></b>"
100
+ else:
101
+ name = f"<y>{escape_tag(self._scene.name)}</y>(<b><e>{name}</e></b>)"
102
+
103
+ if self._scene is not None and self._scene.avatar is not None:
104
+ name = link(name, self._scene.avatar)
105
+
106
+ return name
89
107
 
90
108
  async def send(self, message: str | UniMessage) -> Receipt:
91
109
  if isinstance(message, str):
92
110
  message = UniMessage.text(message)
93
- text = f"<b><e>{self.group.id}</e></b> | <g>Send</g> | "
111
+
112
+ text = f"{self.colored_name} | <g>Send</g> | "
94
113
  for seg in message:
95
114
  if isinstance(seg, At):
96
- text += f"<y>@{self._player_map[seg.target].name}</y>"
115
+ text += f"<y>@{self._player_map[seg.target].colored_name}</y>"
97
116
  else:
98
- text += str(seg)
99
- logger.opt(colors=True).info(text.replace("\n", "\\n"))
117
+ text += escape_tag(str(seg)).replace("\n", "\\n")
118
+
119
+ logger.opt(colors=True).info(text)
100
120
  return await message.send(self.group, self.bot)
101
121
 
102
122
  def at_all(self) -> UniMessage:
@@ -124,80 +144,79 @@ class Game:
124
144
  raise GameFinished(GameStatus.GoodGuy)
125
145
 
126
146
  def show_killed_players(self) -> str:
127
- msg = ""
147
+ result: list[str] = []
128
148
 
129
149
  for player in self.killed_players:
130
150
  if player.kill_info is None:
131
151
  continue
132
152
 
133
- msg += f"{player.name} 被 " + ", ".join(
153
+ line = f"{player.name} 被 " + ", ".join(
134
154
  p.name for p in player.kill_info.killers
135
155
  )
136
156
  match player.kill_info.reason:
137
157
  case KillReason.Werewolf:
138
- msg += " 刀了"
158
+ line = f"🔪 {line} 刀了"
139
159
  case KillReason.Poison:
140
- msg += " 毒死"
160
+ line = f"🧪 {line} 毒死"
141
161
  case KillReason.Shoot:
142
- msg += " 射杀"
162
+ line = f"🔫 {line} 射杀"
143
163
  case KillReason.Vote:
144
- msg += " 票出"
145
- msg += "\n\n"
164
+ line = f"🗳️ {line} 票出"
165
+ case x:
166
+ assert_never(x)
167
+ result.append(line)
146
168
 
147
- return msg.strip()
169
+ return "\n\n".join(result)
148
170
 
149
171
  async def notify_player_role(self) -> None:
150
172
  preset = config.get_role_preset()[len(self.players)]
151
- await asyncio.gather(
152
- self.send(
153
- self.at_all()
154
- .text("\n\n正在分配职业,请注意查看私聊消息\n")
155
- .text(f"当前玩家数: {len(self.players)}\n")
156
- .text(f"职业分配: 狼人x{preset[0]}, 神职x{preset[1]}, 平民x{preset[2]}")
157
- ),
158
- *[p.notify_role() for p in self.players],
173
+ msg = (
174
+ self.at_all()
175
+ .text("\n\n📝正在分配职业,请注意查看私聊消息\n")
176
+ .text(f"当前玩家数: {len(self.players)}\n")
177
+ .text(f"职业分配: 狼人x{preset[0]}, 神职x{preset[1]}, 平民x{preset[2]}")
159
178
  )
160
179
 
161
- async def wait_stop(
162
- self,
163
- players: Player | PlayerSet,
164
- timeout_secs: float,
165
- ) -> None:
166
- if isinstance(players, Player):
167
- players = PlayerSet([players])
180
+ async with anyio.create_task_group() as tg:
181
+ tg.start_soon(self.send, msg)
182
+ for p in self.players:
183
+ tg.start_soon(p.notify_role)
168
184
 
185
+ async def wait_stop(self, *players: Player, timeout_secs: float) -> None:
169
186
  async def wait(p: Player) -> None:
170
187
  while True:
171
188
  msg = await InputStore.fetch(p.user_id, self.group.id)
172
- if msg.extract_plain_text() == "/stop":
189
+ if msg.extract_plain_text().strip() == STOP_COMMAND:
173
190
  break
174
191
 
175
- with contextlib.suppress(TimeoutError):
176
- async with timeout(timeout_secs):
177
- await asyncio.gather(*[wait(p) for p in players])
192
+ with anyio.move_on_after(timeout_secs):
193
+ async with anyio.create_task_group() as tg:
194
+ for p in players:
195
+ tg.start_soon(wait, p)
178
196
 
179
197
  async def interact(
180
198
  self,
181
- type_: Player | Role | RoleGroup,
199
+ player_type: Player | Role | RoleGroup,
182
200
  timeout_secs: float,
183
201
  ) -> None:
184
- players = self.players.alive().select(type_)
185
- text = (
186
- type_.role_name # Player
187
- if isinstance(type_, Player)
188
- else (
189
- role_name_conv[type_] # Role
190
- if isinstance(type_, Role)
191
- else f"{role_name_conv[type_]}阵营" # RoleGroup
192
- )
193
- )
194
-
195
- await players.broadcast(f"{text}交互开始,限时 {timeout_secs/60:.2f} 分钟")
202
+ players = self.players.alive().select(player_type)
203
+ match player_type:
204
+ case Player():
205
+ text = player_type.role_name
206
+ case Role():
207
+ text = role_name_conv[player_type]
208
+ case RoleGroup():
209
+ text = f"{role_name_conv[player_type]}阵营"
210
+ case x:
211
+ assert_never(x)
212
+
213
+ await players.broadcast(f"✏️{text}交互开始,限时 {timeout_secs/60:.2f} 分钟")
196
214
  try:
197
- await players.interact(timeout_secs)
215
+ with anyio.fail_after(timeout_secs):
216
+ await players.interact()
198
217
  except TimeoutError:
199
218
  logger.opt(colors=True).debug(f"{text}交互超时 (<y>{timeout_secs}</y>s)")
200
- await players.broadcast(f"{text}交互时间结束")
219
+ await players.broadcast(f"⚠️{text}交互超时")
201
220
 
202
221
  async def select_killed(self) -> None:
203
222
  players = self.players.alive()
@@ -207,16 +226,16 @@ class Game:
207
226
  await self.interact(RoleGroup.Werewolf, 120)
208
227
  if (s := w.player_selected()).size == 1:
209
228
  self.state.killed = s.pop()
210
- await w.broadcast(f"今晚选择的目标为: {self.state.killed.name}")
229
+ await w.broadcast(f"🔪今晚选择的目标为: {self.state.killed.name}")
211
230
  else:
212
- await w.broadcast("狼人阵营意见未统一,此晚空刀")
231
+ await w.broadcast("⚠️狼人阵营意见未统一,此晚空刀")
213
232
 
214
233
  # 如果女巫存活,正常交互,限时1分钟
215
234
  if players.include(Role.Witch):
216
235
  await self.interact(Role.Witch, 60)
217
236
  # 否则等待 5-20s
218
237
  else:
219
- await asyncio.sleep(5 + secrets.randbelow(15))
238
+ await anyio.sleep(5 + secrets.randbelow(15))
220
239
 
221
240
  async def handle_new_dead(self, players: Player | PlayerSet) -> None:
222
241
  if isinstance(players, Player):
@@ -224,15 +243,16 @@ class Game:
224
243
  if not players:
225
244
  return
226
245
 
227
- await asyncio.gather(
228
- players.broadcast(
229
- "你已加入死者频道,请勿在群内继续发言\n"
230
- "私聊发送消息将转发至其他已死亡玩家"
231
- ),
232
- self.players.dead()
233
- .exclude(*players)
234
- .broadcast(f"玩家 {', '.join(p.name for p in players)} 加入了死者频道"),
235
- )
246
+ async with anyio.create_task_group() as tg:
247
+ tg.start_soon(
248
+ players.broadcast,
249
+ "ℹ️你已加入死者频道,请勿在群内继续发言\n"
250
+ "私聊发送消息将转发至其他已死亡玩家",
251
+ )
252
+ tg.start_soon(
253
+ self.players.dead().exclude(*players).broadcast,
254
+ f"ℹ️玩家 {', '.join(p.name for p in players)} 加入了死者频道",
255
+ )
236
256
 
237
257
  async def post_kill(self, players: Player | PlayerSet) -> None:
238
258
  if isinstance(players, Player):
@@ -245,16 +265,16 @@ class Game:
245
265
  await self.handle_new_dead(player)
246
266
  self.killed_players.append(player)
247
267
 
248
- (shooter, shoot) = self.state.shoot
249
- if shooter is not None and shoot is not None:
268
+ shooter = self.state.shoot
269
+ if shooter is not None and (shoot := shooter.selected) is not None:
250
270
  await self.send(
251
- UniMessage.text("玩家 ")
271
+ UniMessage.text("🔫玩家 ")
252
272
  .at(shoot.user_id)
253
273
  .text(f" 被{shooter.role_name}射杀, 请发表遗言\n")
254
- .text("限时1分钟, 发送 “/stop” 结束发言")
274
+ .text(f"限时1分钟, 发送 “{STOP_COMMAND_PROMPT}” 结束发言")
255
275
  )
256
- await self.wait_stop(shoot, 60)
257
- self.state.shoot = (None, None)
276
+ await self.wait_stop(shoot, timeout_secs=60)
277
+ self.state.shoot = shooter.selected = None
258
278
  await self.post_kill(shoot)
259
279
 
260
280
  async def run_vote(self) -> None:
@@ -262,7 +282,7 @@ class Game:
262
282
  players = self.players.alive()
263
283
 
264
284
  # 被票玩家: [投票玩家]
265
- vote_result: dict[Player, list[Player]] = await players.vote(60)
285
+ vote_result: dict[Player, list[Player]] = await players.vote()
266
286
  # 票数: [被票玩家]
267
287
  vote_reversed: dict[int, list[Player]] = {}
268
288
  # 收集到的总票数
@@ -271,34 +291,35 @@ class Game:
271
291
  logger.debug(f"投票结果: {vote_result}")
272
292
 
273
293
  # 投票结果公示
274
- msg = UniMessage.text("投票结果:\n")
294
+ msg = UniMessage.text("📊投票结果:\n")
275
295
  for p, v in sorted(vote_result.items(), key=lambda x: len(x[1]), reverse=True):
276
296
  if p is not None:
277
297
  msg.at(p.user_id).text(f": {len(v)} 票\n")
278
298
  vote_reversed[len(v)] = [*vote_reversed.get(len(v), []), p]
279
- if v := (len(players) - total_votes):
280
- msg.text(f"弃票: {v} 票\n")
281
- await self.send(msg)
299
+ if (v := (len(players) - total_votes)) > 0:
300
+ msg.text(f"弃票: {v} 票\n\n")
282
301
 
283
302
  # 全员弃票 # 不是哥们?
284
303
  if total_votes == 0:
285
- await self.send("没有人被票出")
304
+ await self.send(msg.text("🔨没有人被投票放逐"))
286
305
  return
287
306
 
288
307
  # 弃票大于最高票
289
308
  if (len(players) - total_votes) >= max(vote_reversed.keys()):
290
- await self.send("弃票数大于最高票数, 没有人被票出")
309
+ await self.send(msg.text("🔨弃票数大于最高票数, 没有人被投票放逐"))
291
310
  return
292
311
 
293
312
  # 平票
294
313
  if len(vs := vote_reversed[max(vote_reversed.keys())]) != 1:
295
314
  await self.send(
296
- UniMessage.text("玩家 ")
315
+ msg.text("🔨玩家 ")
297
316
  .text(", ".join(p.name for p in vs))
298
- .text(" 平票, 没有人被票出")
317
+ .text(" 平票, 没有人被投票放逐")
299
318
  )
300
319
  return
301
320
 
321
+ await self.send(msg.rstrip("\n"))
322
+
302
323
  # 仅有一名玩家票数最高
303
324
  voted = vs.pop()
304
325
  if not await voted.kill(KillReason.Vote, *vote_result[voted]):
@@ -307,68 +328,82 @@ class Game:
307
328
 
308
329
  # 遗言
309
330
  await self.send(
310
- UniMessage.text("玩家 ")
331
+ UniMessage.text("🔨玩家 ")
311
332
  .at(voted.user_id)
312
333
  .text(" 被投票放逐, 请发表遗言\n")
313
- .text("限时1分钟, 发送 “/stop” 结束发言")
334
+ .text(f"限时1分钟, 发送 “{STOP_COMMAND_PROMPT}” 结束发言")
314
335
  )
315
- await self.wait_stop(voted, 60)
336
+ await self.wait_stop(voted, timeout_secs=60)
316
337
  await self.post_kill(voted)
317
338
 
318
- async def run_dead_channel(self) -> None:
319
- loop = asyncio.get_event_loop()
320
- queue: asyncio.Queue[tuple[Player, UniMessage]] = asyncio.Queue()
339
+ async def run_dead_channel(self, finished: anyio.Event) -> NoReturn:
340
+ send, recv = anyio.create_memory_object_stream[tuple[Player, UniMessage]](10)
341
+
342
+ async def handle_cancel() -> None:
343
+ await finished.wait()
344
+ tg.cancel_scope.cancel()
321
345
 
322
- async def send() -> NoReturn:
346
+ async def handle_send() -> NoReturn:
323
347
  while True:
324
- player, msg = await queue.get()
348
+ player, msg = await recv.receive()
325
349
  msg = f"玩家 {player.name}:\n" + msg
326
350
  await self.players.killed().exclude(player).broadcast(msg)
327
- queue.task_done()
328
351
 
329
- async def recv(player: Player) -> NoReturn:
352
+ async def handle_recv(player: Player) -> NoReturn:
330
353
  await player.killed.wait()
331
354
 
332
355
  counter = 0
333
356
 
334
- def decrease() -> None:
357
+ async def decrease() -> None:
335
358
  nonlocal counter
359
+ await anyio.sleep(60)
336
360
  counter -= 1
337
361
 
338
362
  while True:
339
363
  msg = await player.receive()
340
364
  counter += 1
341
365
  if counter <= 10:
342
- await queue.put((player, msg))
343
- loop.call_later(60, decrease)
366
+ await send.send((player, msg))
367
+ tg.start_soon(decrease)
344
368
  else:
345
- await player.send("发言频率超过限制, 该消息被屏蔽")
369
+ await player.send("❌发言频率超过限制, 该消息被屏蔽")
346
370
 
347
- await asyncio.gather(send(), *[recv(p) for p in self.players])
371
+ async with anyio.create_task_group() as tg:
372
+ tg.start_soon(handle_cancel)
373
+ tg.start_soon(handle_send)
374
+ for p in self.players:
375
+ tg.start_soon(handle_recv, p)
348
376
 
349
377
  async def run(self) -> NoReturn:
378
+ await self._fetch_group_scene()
350
379
  # 告知玩家角色信息
351
380
  await self.notify_player_role()
352
- # 天数记录 主要用于第一晚狼人击杀的遗言
353
- day_count = 0
354
381
 
355
382
  # 游戏主循环
356
383
  while True:
357
384
  # 重置游戏状态,进入下一夜
358
- self.state = GameState(day_count)
385
+ self.state.reset()
359
386
  players = self.players.alive()
360
- await self.send("天黑请闭眼...")
387
+ await self.send("🌙天黑请闭眼...")
361
388
 
362
389
  # 狼人、预言家、守卫 同时交互,女巫在狼人后交互
363
- await asyncio.gather(
364
- self.select_killed(),
365
- self.interact(Role.Prophet, 60),
366
- self.interact(Role.Guard, 60),
367
- players.select(Role.Witch).broadcast("请等待狼人决定目标..."),
368
- players.exclude(
369
- RoleGroup.Werewolf, Role.Prophet, Role.Witch, Role.Guard
370
- ).broadcast("请等待其他玩家结束交互..."),
371
- )
390
+ async with anyio.create_task_group() as tg:
391
+ tg.start_soon(self.select_killed)
392
+ tg.start_soon(
393
+ players.select(Role.Witch).broadcast,
394
+ "ℹ️请等待狼人决定目标...",
395
+ )
396
+ tg.start_soon(self.interact, Role.Prophet, 60)
397
+ tg.start_soon(self.interact, Role.Guard, 60)
398
+ tg.start_soon(
399
+ players.exclude(
400
+ RoleGroup.Werewolf,
401
+ Role.Prophet,
402
+ Role.Witch,
403
+ Role.Guard,
404
+ ).broadcast,
405
+ "ℹ️请等待其他玩家结束交互...",
406
+ )
372
407
 
373
408
  # 狼人击杀目标
374
409
  if (
@@ -390,41 +425,46 @@ class Game:
390
425
  # 女巫毒杀玩家
391
426
  await witch.selected.kill(KillReason.Poison, witch)
392
427
 
393
- day_count += 1
394
- msg = UniMessage.text(f"『第{day_count}天』天亮了...\n")
428
+ self.state.day += 1
429
+ msg = UniMessage.text(f"『第{self.state.day}天』☀️天亮了...\n")
395
430
  # 没有玩家死亡,平安夜
396
431
  if not (dead := players.dead()):
397
432
  await self.send(msg.text("昨晚是平安夜"))
398
433
  # 有玩家死亡,公布死者名单
399
434
  else:
400
- msg.text("昨晚的死者是:")
401
- for p in dead.sorted():
435
+ msg.text("☠️昨晚的死者是:")
436
+ for p in dead.sorted:
402
437
  msg.text("\n").at(p.user_id)
403
438
  await self.send(msg)
404
439
 
405
440
  # 第一晚被狼人杀死的玩家发表遗言
406
- if day_count == 1 and killed is not None and not killed.alive:
441
+ if self.state.day == 1 and killed is not None and not killed.alive:
407
442
  await self.send(
408
- UniMessage.text("当前为第一天\n请被狼人杀死的 ")
443
+ UniMessage.text("⚙️当前为第一天\n请被狼人杀死的 ")
409
444
  .at(killed.user_id)
410
445
  .text(" 发表遗言\n")
411
- .text("限时1分钟, 发送 “/stop” 结束发言")
446
+ .text(f"限时1分钟, 发送 “{STOP_COMMAND_PROMPT}” 结束发言")
412
447
  )
413
- await self.wait_stop(killed, 60)
448
+ await self.wait_stop(killed, timeout_secs=60)
414
449
  await self.post_kill(dead)
415
450
 
416
451
  # 判断游戏状态
417
452
  self.check_game_status()
418
453
 
419
454
  # 公示存活玩家
420
- await self.send(f"当前存活玩家: \n\n{self.players.alive().show()}")
455
+ await self.send(f"📝当前存活玩家: \n\n{self.players.alive().show()}")
421
456
 
422
457
  # 开始自由讨论
423
- await self.send("接下来开始自由讨论\n限时2分钟, 全员发送 “/stop” 结束发言")
424
- await self.wait_stop(self.players.alive(), 120)
458
+ await self.send(
459
+ "💬接下来开始自由讨论\n限时2分钟, "
460
+ f"全员发送 “{STOP_COMMAND_PROMPT}” 结束发言"
461
+ )
462
+ await self.wait_stop(*self.players.alive(), timeout_secs=120)
425
463
 
426
464
  # 开始投票
427
- await self.send("讨论结束, 进入投票环节,限时1分钟\n请在私聊中进行投票交互")
465
+ await self.send(
466
+ "🗳️讨论结束, 进入投票环节,限时1分钟\n请在私聊中进行投票交互"
467
+ )
428
468
  await self.run_vote()
429
469
 
430
470
  # 判断游戏状态
@@ -438,6 +478,8 @@ class Game:
438
478
  winner = "狼人"
439
479
  case GameStatus.Joker:
440
480
  winner = "小丑"
481
+ case x:
482
+ assert_never(x)
441
483
 
442
484
  msg = UniMessage.text(f"🎉游戏结束,{winner}获胜\n\n")
443
485
  for p in sorted(self.players, key=lambda p: (p.role.value, p.user_id)):
@@ -445,35 +487,31 @@ class Game:
445
487
  await self.send(msg)
446
488
  await self.send(f"📌玩家死亡报告:\n\n{self.show_killed_players()}")
447
489
 
448
- def start(self) -> None:
449
- finished = asyncio.Event()
450
- game_task = asyncio.create_task(self.run())
451
- game_task.add_done_callback(lambda _: finished.set())
452
- dead_channel = asyncio.create_task(self.run_dead_channel())
453
-
490
+ async def start(self) -> None:
454
491
  async def daemon() -> None:
455
- await finished.wait()
456
-
457
492
  try:
458
- game_task.result()
459
- except asyncio.CancelledError:
493
+ await self.run()
494
+ except anyio.get_cancelled_exc_class():
460
495
  logger.warning(f"{self.group.id} 的狼人杀游戏进程被取消")
461
496
  except GameFinished as result:
462
497
  await self.handle_game_finish(result.status)
463
498
  logger.info(f"{self.group.id} 的狼人杀游戏进程正常退出")
464
499
  except Exception as err:
465
500
  msg = f"{self.group.id} 的狼人杀游戏进程出现未知错误: {err!r}"
466
- logger.opt(exception=err).error(msg)
467
- await self.send(msg)
501
+ logger.exception(msg)
502
+ await self.send(f"❌狼人杀游戏进程出现未知错误: {err!r}")
468
503
  finally:
469
- dead_channel.cancel()
470
- running_games.pop(self.group.id, None)
504
+ finished.set()
471
505
 
472
- def daemon_callback(task: asyncio.Task[None]) -> None:
473
- if err := task.exception():
474
- logger.opt(exception=err).error(
475
- f"{self.group.id} 的狼人杀守护进程出现错误: {err!r}"
476
- )
477
-
478
- running_games[self.group.id] = self
479
- asyncio.create_task(daemon()).add_done_callback(daemon_callback)
506
+ finished = anyio.Event()
507
+ self.running_games.add(self)
508
+ try:
509
+ async with anyio.create_task_group() as tg:
510
+ tg.start_soon(daemon)
511
+ tg.start_soon(self.run_dead_channel, finished)
512
+ except Exception as err:
513
+ msg = f"{self.group.id} 的狼人杀守护进程出现错误: {err!r}"
514
+ logger.opt(exception=err).error(msg)
515
+ finally:
516
+ self.running_games.discard(self)
517
+ InputStore.cleanup(list(self._player_map), self.group.id)
@@ -0,0 +1,2 @@
1
+ from . import message_in_game as message_in_game
2
+ from . import start_game as start_game