nonebot-plugin-werewolf 1.1.3__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 (29) hide show
  1. nonebot_plugin_werewolf/__init__.py +2 -1
  2. nonebot_plugin_werewolf/constant.py +23 -1
  3. nonebot_plugin_werewolf/game.py +172 -113
  4. nonebot_plugin_werewolf/matchers/depends.py +38 -0
  5. nonebot_plugin_werewolf/matchers/message_in_game.py +14 -4
  6. nonebot_plugin_werewolf/matchers/poke/__init__.py +8 -0
  7. nonebot_plugin_werewolf/matchers/poke/chronocat_poke.py +117 -0
  8. nonebot_plugin_werewolf/matchers/{ob11_ext.py → poke/ob11_poke.py} +21 -19
  9. nonebot_plugin_werewolf/matchers/start_game.py +161 -15
  10. nonebot_plugin_werewolf/player_set.py +33 -34
  11. nonebot_plugin_werewolf/players/can_shoot.py +12 -18
  12. nonebot_plugin_werewolf/players/civilian.py +2 -2
  13. nonebot_plugin_werewolf/players/guard.py +15 -22
  14. nonebot_plugin_werewolf/players/hunter.py +2 -2
  15. nonebot_plugin_werewolf/players/idiot.py +3 -3
  16. nonebot_plugin_werewolf/players/joker.py +2 -2
  17. nonebot_plugin_werewolf/players/player.py +137 -65
  18. nonebot_plugin_werewolf/players/prophet.py +7 -15
  19. nonebot_plugin_werewolf/players/werewolf.py +51 -29
  20. nonebot_plugin_werewolf/players/witch.py +32 -38
  21. nonebot_plugin_werewolf/players/wolfking.py +2 -2
  22. nonebot_plugin_werewolf/utils.py +56 -190
  23. {nonebot_plugin_werewolf-1.1.3.dist-info → nonebot_plugin_werewolf-1.1.5.dist-info}/METADATA +14 -2
  24. nonebot_plugin_werewolf-1.1.5.dist-info/RECORD +31 -0
  25. {nonebot_plugin_werewolf-1.1.3.dist-info → nonebot_plugin_werewolf-1.1.5.dist-info}/WHEEL +1 -1
  26. nonebot_plugin_werewolf/_timeout.py +0 -110
  27. nonebot_plugin_werewolf-1.1.3.dist-info/RECORD +0 -29
  28. {nonebot_plugin_werewolf-1.1.3.dist-info → nonebot_plugin_werewolf-1.1.5.dist-info}/LICENSE +0 -0
  29. {nonebot_plugin_werewolf-1.1.3.dist-info → nonebot_plugin_werewolf-1.1.5.dist-info}/top_level.txt +0 -0
@@ -9,7 +9,7 @@ from . import matchers as matchers
9
9
  from . import players as players
10
10
  from .config import Config
11
11
 
12
- __version__ = "1.1.3"
12
+ __version__ = "1.1.5"
13
13
  __plugin_meta__ = PluginMetadata(
14
14
  name="狼人杀",
15
15
  description="适用于 Nonebot2 的狼人杀插件",
@@ -24,6 +24,7 @@ __plugin_meta__ = PluginMetadata(
24
24
  ),
25
25
  extra={
26
26
  "Author": "wyf7685",
27
+ "Version": __version__,
27
28
  "Bug Tracker": "https://github.com/wyf7685/nonebot-plugin-werewolf/issues",
28
29
  },
29
30
  )
@@ -4,10 +4,19 @@ import dataclasses
4
4
  from enum import Enum, auto
5
5
  from typing import TYPE_CHECKING
6
6
 
7
+ import nonebot
8
+
7
9
  if TYPE_CHECKING:
8
10
  from .players import Player
9
11
 
10
12
 
13
+ COMMAND_START = next(
14
+ iter(sorted(nonebot.get_driver().config.command_start, key=len)), ""
15
+ )
16
+ STOP_COMMAND_PROMPT = f"{COMMAND_START}stop"
17
+ STOP_COMMAND = "{{stop}}"
18
+
19
+
11
20
  class Role(Enum):
12
21
  # 狼人
13
22
  Werewolf = 1
@@ -49,11 +58,24 @@ class GameStatus(Enum):
49
58
  @dataclasses.dataclass
50
59
  class GameState:
51
60
  day: int
61
+ """当前天数记录, 不会被 `reset()` 重置"""
52
62
  killed: Player | None = None
53
- shoot: tuple[Player, Player] | tuple[None, None] = (None, None)
63
+ """当晚狼人击杀目标, `None` 则为空刀"""
64
+ shoot: Player | None = None
65
+ """当前执行射杀操作的玩家"""
54
66
  antidote: set[Player] = dataclasses.field(default_factory=set)
67
+ """当晚女巫使用解药的目标"""
55
68
  poison: set[Player] = dataclasses.field(default_factory=set)
69
+ """当晚使用了毒药的女巫"""
56
70
  protected: set[Player] = dataclasses.field(default_factory=set)
71
+ """当晚守卫保护的目标"""
72
+
73
+ def reset(self) -> None:
74
+ self.killed = None
75
+ self.shoot = None
76
+ self.antidote = set()
77
+ self.poison = set()
78
+ self.protected = set()
57
79
 
58
80
 
59
81
  role_name_conv: dict[Role | RoleGroup, str] = {
@@ -1,27 +1,33 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import contextlib
5
1
  import secrets
6
- from typing import TYPE_CHECKING, ClassVar, 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
25
  from .player_set import PlayerSet
16
26
  from .players import Player
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
27
+ from .utils import InputStore, link
22
28
 
23
29
 
24
- def init_players(bot: Bot, game: Game, players: dict[str, str]) -> PlayerSet:
30
+ def init_players(bot: Bot, game: "Game", players: set[str]) -> PlayerSet:
25
31
  logger.opt(colors=True).debug(f"初始化 <c>{game.group.id}</c> 的玩家职业")
26
32
  role_preset = config.get_role_preset()
27
33
  if (preset := role_preset.get(len(players))) is None:
@@ -44,8 +50,7 @@ def init_players(bot: Bot, game: Game, players: dict[str, str]) -> PlayerSet:
44
50
  return roles.pop(secrets.randbelow(len(roles)))
45
51
 
46
52
  player_set = PlayerSet(
47
- Player.new(_select_role(), bot, game, user_id, players[user_id])
48
- for user_id in players
53
+ Player.new(_select_role(), bot, game, user_id) for user_id in players
49
54
  )
50
55
  logger.debug(f"职业分配完成: {player_set}")
51
56
 
@@ -54,33 +59,64 @@ def init_players(bot: Bot, game: Game, players: dict[str, str]) -> PlayerSet:
54
59
 
55
60
  class Game:
56
61
  starting_games: ClassVar[dict[Target, dict[str, str]]] = {}
57
- running_games: ClassVar[set[Game]] = set()
62
+ running_games: ClassVar[set[Self]] = set()
58
63
 
59
64
  bot: Bot
60
65
  group: Target
61
66
  players: PlayerSet
62
- _player_map: dict[str, Player]
67
+ interface: Interface
63
68
  state: GameState
64
69
  killed_players: list[Player]
65
70
 
66
- 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:
67
78
  self.bot = bot
68
79
  self.group = group
69
80
  self.players = init_players(bot, self, players)
70
- self._player_map = {p.user_id: p for p in self.players}
81
+ self.interface = interface
71
82
  self.state = GameState(0)
72
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
73
107
 
74
108
  async def send(self, message: str | UniMessage) -> Receipt:
75
109
  if isinstance(message, str):
76
110
  message = UniMessage.text(message)
77
- text = f"<b><e>{self.group.id}</e></b> | <g>Send</g> | "
111
+
112
+ text = f"{self.colored_name} | <g>Send</g> | "
78
113
  for seg in message:
79
114
  if isinstance(seg, At):
80
- text += f"<y>@{self._player_map[seg.target].name}</y>"
115
+ text += f"<y>@{self._player_map[seg.target].colored_name}</y>"
81
116
  else:
82
- text += str(seg)
83
- 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)
84
120
  return await message.send(self.group, self.bot)
85
121
 
86
122
  def at_all(self) -> UniMessage:
@@ -126,32 +162,37 @@ class Game:
126
162
  line = f"🔫 {line} 射杀"
127
163
  case KillReason.Vote:
128
164
  line = f"🗳️ {line} 票出"
165
+ case x:
166
+ assert_never(x)
129
167
  result.append(line)
130
168
 
131
169
  return "\n\n".join(result)
132
170
 
133
171
  async def notify_player_role(self) -> None:
134
172
  preset = config.get_role_preset()[len(self.players)]
135
- await asyncio.gather(
136
- self.send(
137
- self.at_all()
138
- .text("\n\n📝正在分配职业,请注意查看私聊消息\n")
139
- .text(f"当前玩家数: {len(self.players)}\n")
140
- .text(f"职业分配: 狼人x{preset[0]}, 神职x{preset[1]}, 平民x{preset[2]}")
141
- ),
142
- *[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]}")
143
178
  )
144
179
 
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)
184
+
145
185
  async def wait_stop(self, *players: Player, timeout_secs: float) -> None:
146
186
  async def wait(p: Player) -> None:
147
187
  while True:
148
188
  msg = await InputStore.fetch(p.user_id, self.group.id)
149
- if msg.extract_plain_text().strip() == "/stop":
189
+ if msg.extract_plain_text().strip() == STOP_COMMAND:
150
190
  break
151
191
 
152
- with contextlib.suppress(TimeoutError):
153
- async with timeout(timeout_secs):
154
- 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)
155
196
 
156
197
  async def interact(
157
198
  self,
@@ -159,19 +200,23 @@ class Game:
159
200
  timeout_secs: float,
160
201
  ) -> None:
161
202
  players = self.players.alive().select(player_type)
162
- if isinstance(player_type, Player):
163
- text = player_type.role_name
164
- elif isinstance(player_type, Role):
165
- text = role_name_conv[player_type]
166
- else: # RoleGroup
167
- text = f"{role_name_conv[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)
168
212
 
169
213
  await players.broadcast(f"✏️{text}交互开始,限时 {timeout_secs/60:.2f} 分钟")
170
214
  try:
171
- await players.interact(timeout_secs)
215
+ with anyio.fail_after(timeout_secs):
216
+ await players.interact()
172
217
  except TimeoutError:
173
- logger.opt(colors=True).debug(f"⚠️{text}交互超时 (<y>{timeout_secs}</y>s)")
174
- await players.broadcast(f"ℹ️{text}交互时间结束")
218
+ logger.opt(colors=True).debug(f"{text}交互超时 (<y>{timeout_secs}</y>s)")
219
+ await players.broadcast(f"⚠️{text}交互超时")
175
220
 
176
221
  async def select_killed(self) -> None:
177
222
  players = self.players.alive()
@@ -190,7 +235,7 @@ class Game:
190
235
  await self.interact(Role.Witch, 60)
191
236
  # 否则等待 5-20s
192
237
  else:
193
- await asyncio.sleep(5 + secrets.randbelow(15))
238
+ await anyio.sleep(5 + secrets.randbelow(15))
194
239
 
195
240
  async def handle_new_dead(self, players: Player | PlayerSet) -> None:
196
241
  if isinstance(players, Player):
@@ -198,15 +243,16 @@ class Game:
198
243
  if not players:
199
244
  return
200
245
 
201
- await asyncio.gather(
202
- players.broadcast(
246
+ async with anyio.create_task_group() as tg:
247
+ tg.start_soon(
248
+ players.broadcast,
203
249
  "ℹ️你已加入死者频道,请勿在群内继续发言\n"
204
- "私聊发送消息将转发至其他已死亡玩家"
205
- ),
206
- self.players.dead()
207
- .exclude(*players)
208
- .broadcast(f"ℹ️玩家 {', '.join(p.name for p in players)} 加入了死者频道"),
209
- )
250
+ "私聊发送消息将转发至其他已死亡玩家",
251
+ )
252
+ tg.start_soon(
253
+ self.players.dead().exclude(*players).broadcast,
254
+ f"ℹ️玩家 {', '.join(p.name for p in players)} 加入了死者频道",
255
+ )
210
256
 
211
257
  async def post_kill(self, players: Player | PlayerSet) -> None:
212
258
  if isinstance(players, Player):
@@ -219,16 +265,16 @@ class Game:
219
265
  await self.handle_new_dead(player)
220
266
  self.killed_players.append(player)
221
267
 
222
- (shooter, shoot) = self.state.shoot
223
- 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:
224
270
  await self.send(
225
- UniMessage.text("玩家 ")
271
+ UniMessage.text("🔫玩家 ")
226
272
  .at(shoot.user_id)
227
273
  .text(f" 被{shooter.role_name}射杀, 请发表遗言\n")
228
- .text("限时1分钟, 发送 “/stop” 结束发言")
274
+ .text(f"限时1分钟, 发送 “{STOP_COMMAND_PROMPT}” 结束发言")
229
275
  )
230
276
  await self.wait_stop(shoot, timeout_secs=60)
231
- self.state.shoot = (None, None)
277
+ self.state.shoot = shooter.selected = None
232
278
  await self.post_kill(shoot)
233
279
 
234
280
  async def run_vote(self) -> None:
@@ -236,7 +282,7 @@ class Game:
236
282
  players = self.players.alive()
237
283
 
238
284
  # 被票玩家: [投票玩家]
239
- vote_result: dict[Player, list[Player]] = await players.vote(60)
285
+ vote_result: dict[Player, list[Player]] = await players.vote()
240
286
  # 票数: [被票玩家]
241
287
  vote_reversed: dict[int, list[Player]] = {}
242
288
  # 收集到的总票数
@@ -250,17 +296,17 @@ class Game:
250
296
  if p is not None:
251
297
  msg.at(p.user_id).text(f": {len(v)} 票\n")
252
298
  vote_reversed[len(v)] = [*vote_reversed.get(len(v), []), p]
253
- if v := (len(players) - total_votes):
299
+ if (v := (len(players) - total_votes)) > 0:
254
300
  msg.text(f"弃票: {v} 票\n\n")
255
301
 
256
302
  # 全员弃票 # 不是哥们?
257
303
  if total_votes == 0:
258
- await self.send(msg.text("🔨没有人被票出"))
304
+ await self.send(msg.text("🔨没有人被投票放逐"))
259
305
  return
260
306
 
261
307
  # 弃票大于最高票
262
308
  if (len(players) - total_votes) >= max(vote_reversed.keys()):
263
- await self.send(msg.text("🔨弃票数大于最高票数, 没有人被票出"))
309
+ await self.send(msg.text("🔨弃票数大于最高票数, 没有人被投票放逐"))
264
310
  return
265
311
 
266
312
  # 平票
@@ -268,11 +314,11 @@ class Game:
268
314
  await self.send(
269
315
  msg.text("🔨玩家 ")
270
316
  .text(", ".join(p.name for p in vs))
271
- .text(" 平票, 没有人被票出")
317
+ .text(" 平票, 没有人被投票放逐")
272
318
  )
273
319
  return
274
320
 
275
- await self.send(msg)
321
+ await self.send(msg.rstrip("\n"))
276
322
 
277
323
  # 仅有一名玩家票数最高
278
324
  voted = vs.pop()
@@ -285,65 +331,79 @@ class Game:
285
331
  UniMessage.text("🔨玩家 ")
286
332
  .at(voted.user_id)
287
333
  .text(" 被投票放逐, 请发表遗言\n")
288
- .text("限时1分钟, 发送 “/stop” 结束发言")
334
+ .text(f"限时1分钟, 发送 “{STOP_COMMAND_PROMPT}” 结束发言")
289
335
  )
290
336
  await self.wait_stop(voted, timeout_secs=60)
291
337
  await self.post_kill(voted)
292
338
 
293
- async def run_dead_channel(self) -> NoReturn:
294
- loop = asyncio.get_event_loop()
295
- 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()
296
345
 
297
- async def send() -> NoReturn:
346
+ async def handle_send() -> NoReturn:
298
347
  while True:
299
- player, msg = await queue.get()
348
+ player, msg = await recv.receive()
300
349
  msg = f"玩家 {player.name}:\n" + msg
301
350
  await self.players.killed().exclude(player).broadcast(msg)
302
- queue.task_done()
303
351
 
304
- async def recv(player: Player) -> NoReturn:
352
+ async def handle_recv(player: Player) -> NoReturn:
305
353
  await player.killed.wait()
306
354
 
307
355
  counter = 0
308
356
 
309
- def decrease() -> None:
357
+ async def decrease() -> None:
310
358
  nonlocal counter
359
+ await anyio.sleep(60)
311
360
  counter -= 1
312
361
 
313
362
  while True:
314
363
  msg = await player.receive()
315
364
  counter += 1
316
365
  if counter <= 10:
317
- await queue.put((player, msg))
318
- loop.call_later(60, decrease)
366
+ await send.send((player, msg))
367
+ tg.start_soon(decrease)
319
368
  else:
320
369
  await player.send("❌发言频率超过限制, 该消息被屏蔽")
321
370
 
322
- 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)
323
376
 
324
377
  async def run(self) -> NoReturn:
378
+ await self._fetch_group_scene()
325
379
  # 告知玩家角色信息
326
380
  await self.notify_player_role()
327
- # 天数记录 主要用于第一晚狼人击杀的遗言
328
- day_count = 0
329
381
 
330
382
  # 游戏主循环
331
383
  while True:
332
384
  # 重置游戏状态,进入下一夜
333
- self.state = GameState(day_count)
385
+ self.state.reset()
334
386
  players = self.players.alive()
335
387
  await self.send("🌙天黑请闭眼...")
336
388
 
337
389
  # 狼人、预言家、守卫 同时交互,女巫在狼人后交互
338
- await asyncio.gather(
339
- self.select_killed(),
340
- self.interact(Role.Prophet, 60),
341
- self.interact(Role.Guard, 60),
342
- players.select(Role.Witch).broadcast("ℹ️请等待狼人决定目标..."),
343
- players.exclude(
344
- RoleGroup.Werewolf, Role.Prophet, Role.Witch, Role.Guard
345
- ).broadcast("ℹ️请等待其他玩家结束交互..."),
346
- )
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
+ )
347
407
 
348
408
  # 狼人击杀目标
349
409
  if (
@@ -365,8 +425,8 @@ class Game:
365
425
  # 女巫毒杀玩家
366
426
  await witch.selected.kill(KillReason.Poison, witch)
367
427
 
368
- day_count += 1
369
- msg = UniMessage.text(f"『第{day_count}天』☀️天亮了...\n")
428
+ self.state.day += 1
429
+ msg = UniMessage.text(f"『第{self.state.day}天』☀️天亮了...\n")
370
430
  # 没有玩家死亡,平安夜
371
431
  if not (dead := players.dead()):
372
432
  await self.send(msg.text("昨晚是平安夜"))
@@ -378,12 +438,12 @@ class Game:
378
438
  await self.send(msg)
379
439
 
380
440
  # 第一晚被狼人杀死的玩家发表遗言
381
- 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:
382
442
  await self.send(
383
443
  UniMessage.text("⚙️当前为第一天\n请被狼人杀死的 ")
384
444
  .at(killed.user_id)
385
445
  .text(" 发表遗言\n")
386
- .text("限时1分钟, 发送 “/stop” 结束发言")
446
+ .text(f"限时1分钟, 发送 “{STOP_COMMAND_PROMPT}” 结束发言")
387
447
  )
388
448
  await self.wait_stop(killed, timeout_secs=60)
389
449
  await self.post_kill(dead)
@@ -396,7 +456,8 @@ class Game:
396
456
 
397
457
  # 开始自由讨论
398
458
  await self.send(
399
- "💬接下来开始自由讨论\n限时2分钟, 全员发送 “/stop” 结束发言"
459
+ "💬接下来开始自由讨论\n限时2分钟, "
460
+ f"全员发送 “{STOP_COMMAND_PROMPT}” 结束发言"
400
461
  )
401
462
  await self.wait_stop(*self.players.alive(), timeout_secs=120)
402
463
 
@@ -417,6 +478,8 @@ class Game:
417
478
  winner = "狼人"
418
479
  case GameStatus.Joker:
419
480
  winner = "小丑"
481
+ case x:
482
+ assert_never(x)
420
483
 
421
484
  msg = UniMessage.text(f"🎉游戏结束,{winner}获胜\n\n")
422
485
  for p in sorted(self.players, key=lambda p: (p.role.value, p.user_id)):
@@ -424,35 +487,31 @@ class Game:
424
487
  await self.send(msg)
425
488
  await self.send(f"📌玩家死亡报告:\n\n{self.show_killed_players()}")
426
489
 
427
- def start(self) -> None:
428
- finished = asyncio.Event()
429
- game_task = asyncio.create_task(self.run())
430
- game_task.add_done_callback(lambda _: finished.set())
431
- dead_channel = asyncio.create_task(self.run_dead_channel())
432
-
490
+ async def start(self) -> None:
433
491
  async def daemon() -> None:
434
- await finished.wait()
435
-
436
492
  try:
437
- game_task.result()
438
- except asyncio.CancelledError:
493
+ await self.run()
494
+ except anyio.get_cancelled_exc_class():
439
495
  logger.warning(f"{self.group.id} 的狼人杀游戏进程被取消")
440
496
  except GameFinished as result:
441
497
  await self.handle_game_finish(result.status)
442
498
  logger.info(f"{self.group.id} 的狼人杀游戏进程正常退出")
443
499
  except Exception as err:
444
500
  msg = f"{self.group.id} 的狼人杀游戏进程出现未知错误: {err!r}"
445
- logger.opt(exception=err).error(msg)
501
+ logger.exception(msg)
446
502
  await self.send(f"❌狼人杀游戏进程出现未知错误: {err!r}")
447
503
  finally:
448
- dead_channel.cancel()
449
- self.running_games.discard(self)
450
-
451
- def daemon_callback(task: asyncio.Task[None]) -> None:
452
- if err := task.exception():
453
- msg = f"{self.group.id} 的狼人杀守护进程出现错误: {err!r}"
454
- logger.opt(exception=err).error(msg)
504
+ finished.set()
455
505
 
506
+ finished = anyio.Event()
456
507
  self.running_games.add(self)
457
- daemon_task = asyncio.create_task(daemon())
458
- daemon_task.add_done_callback(daemon_callback)
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,38 @@
1
+ import itertools
2
+
3
+ from nonebot.adapters import Bot, Event
4
+ from nonebot_plugin_alconna import MsgTarget
5
+
6
+ from ..game import Game
7
+
8
+
9
+ def user_in_game(self_id: str, user_id: str, group_id: str | None) -> bool:
10
+ if group_id is None:
11
+ return any(
12
+ self_id == p.bot.self_id and user_id == p.user_id
13
+ for p in itertools.chain(*[g.players for g in Game.running_games])
14
+ )
15
+
16
+ def check(game: Game) -> bool:
17
+ return self_id == game.group.self_id and group_id == game.group.id
18
+
19
+ if game := next(filter(check, Game.running_games), None):
20
+ return any(user_id == player.user_id for player in game.players)
21
+
22
+ return False
23
+
24
+
25
+ async def rule_in_game(bot: Bot, event: Event, target: MsgTarget) -> bool:
26
+ if not Game.running_games:
27
+ return False
28
+ if target.private:
29
+ return user_in_game(bot.self_id, target.id, None)
30
+ return user_in_game(bot.self_id, event.get_user_id(), target.id)
31
+
32
+
33
+ async def rule_not_in_game(bot: Bot, event: Event, target: MsgTarget) -> bool:
34
+ return not await rule_in_game(bot, event, target)
35
+
36
+
37
+ async def is_group(target: MsgTarget) -> bool:
38
+ return not target.private
@@ -1,10 +1,12 @@
1
- from nonebot import on_message
1
+ from nonebot import on_command, on_message
2
2
  from nonebot.adapters import Event
3
- from nonebot_plugin_alconna import MsgTarget, UniMsg
3
+ from nonebot_plugin_alconna import MsgTarget, UniMessage, UniMsg
4
4
 
5
- from ..utils import InputStore, rule_in_game
5
+ from ..constant import STOP_COMMAND
6
+ from ..utils import InputStore
7
+ from .depends import rule_in_game
6
8
 
7
- message_in_game = on_message(rule=rule_in_game)
9
+ message_in_game = on_message(rule=rule_in_game, priority=10)
8
10
 
9
11
 
10
12
  @message_in_game.handle()
@@ -13,3 +15,11 @@ async def handle_input(event: Event, target: MsgTarget, msg: UniMsg) -> None:
13
15
  InputStore.put(msg, target.id)
14
16
  else:
15
17
  InputStore.put(msg, event.get_user_id(), target.id)
18
+
19
+
20
+ stopcmd = on_command("stop", rule=rule_in_game, block=True)
21
+
22
+
23
+ @stopcmd.handle()
24
+ async def handle_stopcmd(event: Event, target: MsgTarget) -> None:
25
+ await handle_input(event=event, target=target, msg=UniMessage.text(STOP_COMMAND))
@@ -0,0 +1,8 @@
1
+ from .chronocat_poke import chronocat_poke_enabled
2
+ from .ob11_poke import ob11_poke_enabled
3
+
4
+ checks = [chronocat_poke_enabled, ob11_poke_enabled]
5
+
6
+
7
+ def poke_enabled() -> bool:
8
+ return any(check() for check in checks)