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