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