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