nonebot-plugin-werewolf 1.1.8__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 (29) hide show
  1. nonebot_plugin_werewolf/__init__.py +1 -1
  2. nonebot_plugin_werewolf/config.py +1 -0
  3. nonebot_plugin_werewolf/constant.py +6 -1
  4. nonebot_plugin_werewolf/game.py +61 -50
  5. nonebot_plugin_werewolf/matchers/edit_behavior.py +14 -2
  6. nonebot_plugin_werewolf/matchers/poke/chronocat_poke.py +2 -2
  7. nonebot_plugin_werewolf/matchers/poke/ob11_poke.py +2 -2
  8. nonebot_plugin_werewolf/matchers/start_game.py +31 -30
  9. nonebot_plugin_werewolf/models.py +15 -3
  10. nonebot_plugin_werewolf/{players/player.py → player.py} +148 -70
  11. nonebot_plugin_werewolf/player_set.py +39 -23
  12. nonebot_plugin_werewolf/players/__init__.py +0 -1
  13. nonebot_plugin_werewolf/players/civilian.py +3 -3
  14. nonebot_plugin_werewolf/players/guard.py +20 -15
  15. nonebot_plugin_werewolf/players/hunter.py +6 -5
  16. nonebot_plugin_werewolf/players/idiot.py +25 -17
  17. nonebot_plugin_werewolf/players/jester.py +22 -17
  18. nonebot_plugin_werewolf/players/prophet.py +13 -8
  19. nonebot_plugin_werewolf/players/{can_shoot.py → shooter.py} +10 -10
  20. nonebot_plugin_werewolf/players/werewolf.py +69 -45
  21. nonebot_plugin_werewolf/players/witch.py +30 -23
  22. nonebot_plugin_werewolf/players/wolfking.py +14 -8
  23. nonebot_plugin_werewolf/utils.py +6 -6
  24. {nonebot_plugin_werewolf-1.1.8.dist-info → nonebot_plugin_werewolf-1.1.9.dist-info}/METADATA +9 -2
  25. nonebot_plugin_werewolf-1.1.9.dist-info/RECORD +35 -0
  26. {nonebot_plugin_werewolf-1.1.8.dist-info → nonebot_plugin_werewolf-1.1.9.dist-info}/WHEEL +1 -1
  27. nonebot_plugin_werewolf-1.1.8.dist-info/RECORD +0 -35
  28. {nonebot_plugin_werewolf-1.1.8.dist-info → nonebot_plugin_werewolf-1.1.9.dist-info/licenses}/LICENSE +0 -0
  29. {nonebot_plugin_werewolf-1.1.8.dist-info → nonebot_plugin_werewolf-1.1.9.dist-info}/top_level.txt +0 -0
@@ -10,7 +10,7 @@ from . import matchers as matchers
10
10
  from . import players as players
11
11
  from .config import Config
12
12
 
13
- __version__ = "1.1.8"
13
+ __version__ = "1.1.9"
14
14
  __plugin_meta__ = PluginMetadata(
15
15
  name="狼人杀",
16
16
  description="适用于 Nonebot2 的狼人杀插件",
@@ -56,6 +56,7 @@ class GameBehavior(ConfigFile):
56
56
  show_roles_list_on_start: bool = False
57
57
  speak_in_turn: bool = False
58
58
  dead_channel_rate_limit: int = 8 # per minute
59
+ werewolf_multi_select: bool = False
59
60
 
60
61
  class _Timeout(BaseModel):
61
62
  prepare: int = Field(default=5 * 60, ge=5 * 60)
@@ -1,4 +1,5 @@
1
1
  import functools
2
+ from typing import TYPE_CHECKING
2
3
 
3
4
  import nonebot
4
5
 
@@ -10,13 +11,17 @@ COMMAND_START = next(
10
11
  )
11
12
 
12
13
 
13
- @functools.cache
14
14
  def stop_command_prompt() -> str:
15
15
  from .config import config # circular import
16
16
 
17
17
  return COMMAND_START + config.get_stop_command()[0]
18
18
 
19
19
 
20
+ if not TYPE_CHECKING:
21
+ stop_command_prompt = functools.cache(stop_command_prompt)
22
+ del TYPE_CHECKING
23
+
24
+
20
25
  ROLE_NAME_CONV: dict[Role | RoleGroup, str] = {
21
26
  Role.WEREWOLF: "狼人",
22
27
  Role.WOLFKING: "狼王",
@@ -2,7 +2,8 @@ import contextlib
2
2
  import functools
3
3
  import secrets
4
4
  from collections import defaultdict
5
- from typing import NoReturn
5
+ from typing import NoReturn, final
6
+ from typing_extensions import Self
6
7
 
7
8
  import anyio
8
9
  import nonebot
@@ -16,8 +17,8 @@ from .config import GameBehavior, PresetData
16
17
  from .constant import GAME_STATUS_CONV, REPORT_TEXT, ROLE_EMOJI, ROLE_NAME_CONV
17
18
  from .exception import GameFinished
18
19
  from .models import GameState, GameStatus, KillInfo, KillReason, Role, RoleGroup
20
+ from .player import Player
19
21
  from .player_set import PlayerSet
20
- from .players import Player
21
22
  from .utils import InputStore, ObjectStream, SendHandler, add_stop_button, link
22
23
 
23
24
  logger = nonebot.logger.opt(colors=True)
@@ -33,9 +34,13 @@ def get_running_games() -> set["Game"]:
33
34
  return running_games
34
35
 
35
36
 
36
- def init_players(bot: Bot, game: "Game", players: set[str]) -> PlayerSet:
37
- # group.colored_name not available yet
38
- logger.debug(f"初始化 <c>{game.group_id}</c> 的玩家职业")
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} 的玩家职业")
39
44
 
40
45
  preset_data = PresetData.get()
41
46
  if (preset := preset_data.role_preset.get(len(players))) is None:
@@ -58,11 +63,11 @@ def init_players(bot: Bot, game: "Game", players: set[str]) -> PlayerSet:
58
63
  def _select_role() -> Role:
59
64
  return roles.pop(secrets.randbelow(len(roles)))
60
65
 
61
- player_set = PlayerSet(
62
- Player.new(_select_role(), bot, game, user_id) for user_id in players
63
- )
64
- 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))
65
69
 
70
+ logger.debug(f"职业分配完成: <e>{escape_tag(str(player_set))}</e>")
66
71
  return player_set
67
72
 
68
73
 
@@ -135,7 +140,7 @@ class DeadChannel:
135
140
  # 推送消息
136
141
  await self.stream.send((player, msg))
137
142
 
138
- async def run(self) -> NoReturn:
143
+ async def run(self) -> None:
139
144
  async with anyio.create_task_group() as tg:
140
145
  self._task_group = tg
141
146
  tg.start_soon(self._wait_finished)
@@ -148,36 +153,40 @@ class Game:
148
153
  bot: Bot
149
154
  group: Target
150
155
  players: PlayerSet
151
- interface: Interface
152
156
  state: GameState
153
157
  killed_players: list[tuple[str, KillInfo]]
154
158
 
155
- def __init__(
156
- self,
157
- bot: Bot,
158
- group: Target,
159
- players: set[str],
160
- interface: Interface,
161
- ) -> None:
159
+ def __init__(self, bot: Bot, group: Target) -> None:
162
160
  self.bot = bot
163
161
  self.group = group
164
- self.players = init_players(bot, self, players)
165
- self.interface = interface
166
162
  self.state = GameState(0)
167
163
  self.killed_players = []
168
- self._player_map = {p.user_id: p for p in self.players}
164
+ self._player_map: dict[str, Player] = {}
169
165
  self._scene = None
170
166
  self._finished = None
171
167
  self._task_group = None
172
168
  self._send_handler = _SendHandler()
173
- self._send_handler.update(group)
169
+ self._send_handler.update(group, bot)
174
170
 
175
- async def _fetch_group_scene(self) -> None:
176
- scene = await self.interface.get_scene(SceneType.GROUP, self.group_id)
177
- if scene is None:
178
- scene = await self.interface.get_scene(SceneType.GUILD, self.group_id)
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)
179
185
 
180
- self._scene = scene
186
+ self.players = await init_players(bot, self, players, interface)
187
+ self._player_map |= {p.user_id: p for p in self.players}
188
+
189
+ return self
181
190
 
182
191
  @functools.cached_property
183
192
  def group_id(self) -> str:
@@ -232,6 +241,10 @@ class Game:
232
241
  if not w.size:
233
242
  raise GameFinished(GameStatus.GOODGUY)
234
243
 
244
+ @property
245
+ def behavior(self) -> GameBehavior:
246
+ return GameBehavior.get()
247
+
235
248
  async def notify_player_role(self) -> None:
236
249
  msg = UniMessage()
237
250
  for p in sorted(self.players, key=lambda p: p.user_id):
@@ -244,7 +257,7 @@ class Game:
244
257
  .text(f"职业分配: 狼人x{w}, 神职x{p}, 平民x{c}")
245
258
  )
246
259
 
247
- if GameBehavior.get().show_roles_list_on_start:
260
+ if self.behavior.show_roles_list_on_start:
248
261
  role_cnt: dict[Role, int] = defaultdict(lambda: 0)
249
262
  for role in sorted((p.role for p in self.players), key=lambda r: r.value):
250
263
  role_cnt[role] += 1
@@ -264,7 +277,7 @@ class Game:
264
277
  timeout_secs: float | None = None,
265
278
  ) -> None:
266
279
  if timeout_secs is None:
267
- timeout_secs = GameBehavior.get().timeout.speak
280
+ timeout_secs = self.behavior.timeout.speak
268
281
  with anyio.move_on_after(timeout_secs):
269
282
  async with anyio.create_task_group() as tg:
270
283
  for p in players:
@@ -280,18 +293,18 @@ class Game:
280
293
  await player.post_kill()
281
294
  if player.kill_info is None:
282
295
  continue
283
-
284
296
  self.killed_players.append((player.name, player.kill_info))
285
- shooter = self.state.shoot
297
+
298
+ shooter = self.state.shooter
286
299
  if shooter is not None and (shoot := shooter.selected) is not None:
287
300
  await self.send(
288
301
  UniMessage.text("🔫玩家 ")
289
302
  .at(shoot.user_id)
290
303
  .text(f" 被{shooter.name}射杀, 请发表遗言\n")
291
- .text(GameBehavior.get().timeout.speak_timeout_prompt)
304
+ .text(self.behavior.timeout.speak_timeout_prompt)
292
305
  )
293
306
  await self.wait_stop(shoot)
294
- self.state.shoot = shooter.selected = None
307
+ self.state.shooter = shooter.selected = None
295
308
  await self.post_kill(shoot)
296
309
 
297
310
  async def run_night(self, players: PlayerSet) -> Player | None:
@@ -324,10 +337,9 @@ class Game:
324
337
  return killed
325
338
 
326
339
  async def run_discussion(self) -> None:
327
- behavior = GameBehavior.get()
328
- timeout = behavior.timeout
340
+ timeout = self.behavior.timeout
329
341
 
330
- if not behavior.speak_in_turn:
342
+ if not self.behavior.speak_in_turn:
331
343
  speak_timeout = timeout.group_speak
332
344
  await self.send(
333
345
  f"💬接下来开始自由讨论\n{timeout.group_speak_timeout_prompt}",
@@ -335,11 +347,13 @@ class Game:
335
347
  )
336
348
  await self.wait_stop(*self.players.alive(), timeout_secs=speak_timeout)
337
349
  else:
338
- await self.send("💬接下来开始自由讨论")
350
+ await self.send("💬接下来开始轮流发言")
339
351
  speak_timeout = timeout.speak
340
352
  for player in self.players.alive().sorted:
341
353
  await self.send(
342
- f"💬轮到你发言\n{timeout.speak_timeout_prompt}",
354
+ UniMessage.text("💬")
355
+ .at(player.user_id)
356
+ .text(f"\n轮到你发言\n{timeout.speak_timeout_prompt}"),
343
357
  stop_btn_label="结束发言",
344
358
  )
345
359
  await self.wait_stop(player, timeout_secs=speak_timeout)
@@ -395,7 +409,7 @@ class Game:
395
409
 
396
410
  # 仅有一名玩家票数最高
397
411
  voted = vs.pop()
398
- if not await voted.kill(KillReason.VOTE, *vote_result[voted]):
412
+ if await voted.kill(KillReason.VOTE, *vote_result[voted]) is None:
399
413
  # 投票放逐失败 (例: 白痴)
400
414
  return
401
415
 
@@ -404,7 +418,7 @@ class Game:
404
418
  UniMessage.text("🔨玩家 ")
405
419
  .at(voted.user_id)
406
420
  .text(" 被投票放逐, 请发表遗言\n")
407
- .text(GameBehavior.get().timeout.speak_timeout_prompt),
421
+ .text(self.behavior.timeout.speak_timeout_prompt),
408
422
  stop_btn_label="结束发言",
409
423
  )
410
424
  await self.wait_stop(voted)
@@ -445,7 +459,7 @@ class Game:
445
459
  UniMessage.text("⚙️当前为第一天\n请被狼人杀死的 ")
446
460
  .at(killed.user_id)
447
461
  .text(" 发表遗言\n")
448
- .text(GameBehavior.get().timeout.speak_timeout_prompt),
462
+ .text(self.behavior.timeout.speak_timeout_prompt),
449
463
  stop_btn_label="结束发言",
450
464
  )
451
465
  await self.wait_stop(killed)
@@ -476,7 +490,7 @@ class Game:
476
490
  msg.at(p.user_id).text(f": {p.role_name}\n")
477
491
  await self.send(msg)
478
492
 
479
- report: list[str] = ["📌玩家死亡报告:"]
493
+ report = ["📌玩家死亡报告:"]
480
494
  for name, info in self.killed_players:
481
495
  emoji, action = REPORT_TEXT[info.reason]
482
496
  report.append(f"{emoji} {name} 被 {', '.join(info.killers)} {action}")
@@ -491,24 +505,21 @@ class Game:
491
505
  await self.handle_game_finish(result.status)
492
506
  logger.info(f"{self.colored_name} 的狼人杀游戏进程正常退出")
493
507
  except Exception as err:
494
- msg = f"{self.colored_name} 的狼人杀游戏进程出现未知错误: {err!r}"
495
- logger.exception(msg)
508
+ logger.exception(f"{self.colored_name} 的狼人杀游戏进程出现未知错误")
496
509
  await self.send(f"❌狼人杀游戏进程出现未知错误: {err!r}")
497
510
  finally:
498
511
  if self._finished is not None:
499
512
  self._finished.set()
500
513
 
501
514
  async def start(self) -> None:
502
- await self._fetch_group_scene()
503
515
  self._finished = anyio.Event()
504
516
  dead_channel = DeadChannel(self.players, self._finished)
505
517
  get_running_games().add(self)
506
518
 
507
519
  try:
508
- async with anyio.create_task_group() as tg:
509
- self._task_group = tg
510
- tg.start_soon(self.run_daemon)
511
- 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)
512
523
  except anyio.get_cancelled_exc_class():
513
524
  logger.warning(f"{self.colored_name} 的狼人杀游戏进程被取消")
514
525
  except Exception as err:
@@ -518,7 +529,7 @@ class Game:
518
529
  self._finished = None
519
530
  self._task_group = None
520
531
  get_running_games().discard(self)
521
- InputStore.cleanup(list(self._player_map), self.group_id)
532
+ InputStore.cleanup(self._player_map.keys(), self.group_id)
522
533
 
523
534
  def terminate(self) -> None:
524
535
  if self._task_group is not None:
@@ -44,7 +44,7 @@ alc = Alconna(
44
44
  help_text="设置游戏开始时是否显示职业列表",
45
45
  ),
46
46
  Subcommand(
47
- "speak_order",
47
+ "speak_in_turn",
48
48
  Args["enabled#是否启用", bool],
49
49
  alias={"发言顺序"},
50
50
  help_text="设置是否按顺序发言",
@@ -55,6 +55,12 @@ alc = Alconna(
55
55
  alias={"死亡聊天"},
56
56
  help_text="设置死亡玩家发言频率限制",
57
57
  ),
58
+ Subcommand(
59
+ "werewolf_multi_select",
60
+ Args["enabled#是否启用", bool],
61
+ alias={"狼人多选"},
62
+ help_text="设置狼人多选时是否从已选玩家中随机选择目标, 为否时将视为空刀",
63
+ ),
58
64
  Subcommand(
59
65
  "timeout",
60
66
  Subcommand(
@@ -122,7 +128,7 @@ async def set_show_roles(behavior: Behavior, enabled: bool) -> None:
122
128
  await finish(f"已{'启用' if enabled else '禁用'}游戏开始时显示职业列表")
123
129
 
124
130
 
125
- @edit_behavior.assign("speak_order")
131
+ @edit_behavior.assign("speak_in_turn")
126
132
  async def set_speak_order(behavior: Behavior, enabled: bool) -> None:
127
133
  behavior.speak_in_turn = enabled
128
134
  await finish(f"已{'启用' if enabled else '禁用'}按顺序发言")
@@ -136,6 +142,12 @@ async def set_dead_chat(behavior: Behavior, limit: int) -> None:
136
142
  await finish(f"已设置死亡玩家发言限制为 {limit} 次/分钟")
137
143
 
138
144
 
145
+ @edit_behavior.assign("werewolf_multi_select")
146
+ async def set_werewolf_multi_select(behavior: Behavior, enabled: bool) -> None:
147
+ behavior.werewolf_multi_select = enabled
148
+ await finish(f"已{'启用' if enabled else '禁用'}狼人多选")
149
+
150
+
139
151
  @edit_behavior.assign("timeout.prepare")
140
152
  async def set_prepare_timeout(behavior: Behavior, time: int) -> None:
141
153
  if time < 300:
@@ -76,7 +76,7 @@ with contextlib.suppress(ImportError):
76
76
  user_id=user_id,
77
77
  group_id=(event.guild and event.guild.id) or event.channel.id,
78
78
  )
79
- and any(target.verify(group) for group in get_starting_games())
79
+ and target in get_starting_games()
80
80
  )
81
81
 
82
82
  @on_message(rule=_rule_poke_join).handle()
@@ -86,7 +86,7 @@ with contextlib.suppress(ImportError):
86
86
  target: MsgTarget,
87
87
  ) -> None:
88
88
  user_id = extract_poke_tome(event) or event.get_user_id()
89
- players = next(p for g, p in get_starting_games().items() if target.verify(g))
89
+ players = get_starting_games()[target]
90
90
 
91
91
  if user_id not in players:
92
92
  # XXX:
@@ -50,7 +50,7 @@ with contextlib.suppress(ImportError):
50
50
  return (
51
51
  (event.target_id == event.self_id)
52
52
  and not user_in_game(bot.self_id, user_id, group_id)
53
- and any(target.verify(group) for group in get_starting_games())
53
+ and target in get_starting_games()
54
54
  )
55
55
 
56
56
  @on_notice(rule=_rule_poke_join).handle()
@@ -60,7 +60,7 @@ with contextlib.suppress(ImportError):
60
60
  target: MsgTarget,
61
61
  ) -> None:
62
62
  user_id = event.get_user_id()
63
- players = next(p for g, p in get_starting_games().items() if target.verify(g))
63
+ players = get_starting_games()[target]
64
64
 
65
65
  if event.group_id is None or user_id in players:
66
66
  return
@@ -118,14 +118,15 @@ class PrepareGame:
118
118
  self.send_handler = self._SendHandler()
119
119
  self.logger = nonebot.logger.opt(colors=True)
120
120
  self.shoud_start_game = False
121
- self._msg_handler: dict[str, Callable[[], Awaitable[bool | None]]] = {
121
+ get_starting_games()[self.group] = self.players
122
+
123
+ self._handlers: dict[str, Callable[[], Awaitable[bool | None]]] = {
122
124
  "开始游戏": self._handle_start,
123
125
  "结束游戏": self._handle_end,
124
126
  "加入游戏": self._handle_join,
125
127
  "退出游戏": self._handle_quit,
126
128
  "当前玩家": self._handle_list,
127
129
  }
128
- get_starting_games()[self.group] = self.players
129
130
 
130
131
  async def run(self) -> None:
131
132
  try:
@@ -163,6 +164,33 @@ class PrepareGame:
163
164
  if event is not None:
164
165
  await self.stream.send((event, text, name))
165
166
 
167
+ async def _handle(self) -> None:
168
+ bot = current_bot.get()
169
+ superuser = SuperUser()
170
+
171
+ while not self.stream.closed:
172
+ event, text, name = await self.stream.recv()
173
+ user_id = event.get_user_id()
174
+ colored = f"<y>{escape_tag(name)}</y>(<c>{escape_tag(user_id)}</c>)"
175
+ self.current = self._Current(
176
+ id=user_id,
177
+ name=name,
178
+ colored=colored,
179
+ is_admin=user_id == self.admin_id,
180
+ is_super_user=await superuser(bot, event),
181
+ )
182
+ self.send_handler.update(event)
183
+
184
+ # 更新用户名
185
+ # 当用户通过 chronoca:poke 加入游戏时, 插件无法获取用户名, 原字典值为用户ID
186
+ if user_id in self.players and self.players.get(user_id) != name:
187
+ self.logger.debug(f"更新玩家显示名称: {self.current.colored}")
188
+ self.players[user_id] = name
189
+
190
+ handler = self._handlers.get(text)
191
+ if handler is not None and await handler():
192
+ return
193
+
166
194
  async def _send(self, msg: str | UniMessage) -> None:
167
195
  await self.send_handler.send(msg)
168
196
 
@@ -240,33 +268,6 @@ class PrepareGame:
240
268
  )
241
269
  await self._send("✨当前玩家:\n" + "\n".join(lines))
242
270
 
243
- async def _handle(self) -> None:
244
- bot = current_bot.get()
245
- superuser = SuperUser()
246
-
247
- while not self.stream.closed:
248
- event, text, name = await self.stream.recv()
249
- user_id = event.get_user_id()
250
- colored = f"<y>{escape_tag(name)}</y>(<c>{escape_tag(user_id)}</c>)"
251
- self.current = self._Current(
252
- id=user_id,
253
- name=name,
254
- colored=colored,
255
- is_admin=user_id == self.admin_id,
256
- is_super_user=await superuser(bot, event),
257
- )
258
- self.send_handler.update(event)
259
-
260
- # 更新用户名
261
- # 当用户通过 chronoca:poke 加入游戏时, 插件无法获取用户名, 原字典值为用户ID
262
- if user_id in self.players and self.players.get(user_id) != name:
263
- self.logger.debug(f"更新玩家显示名称: {self.current.colored}")
264
- self.players[user_id] = name
265
-
266
- handler = self._msg_handler.get(text)
267
- if handler is not None and await handler():
268
- return
269
-
270
271
 
271
272
  @start_game.handle()
272
273
  async def handle_notice(target: MsgTarget) -> None:
@@ -328,5 +329,5 @@ async def handle_start(
328
329
  await UniMessage.text("⚠️游戏准备超时,已自动结束").finish()
329
330
 
330
331
  dump_players(target, players)
331
- game = Game(bot, target, set(players), interface)
332
+ game = await Game.new(bot, target, set(players), interface)
332
333
  await game.start()
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
5
5
  import anyio
6
6
 
7
7
  if TYPE_CHECKING:
8
- from .players import Player
8
+ from .player import Player
9
9
 
10
10
 
11
11
  class Role(int, Enum):
@@ -57,11 +57,13 @@ class GameState:
57
57
  """当前天数记录, 不会被 `reset()` 重置"""
58
58
  state: State = State.NIGHT
59
59
  """当前游戏状态, 不会被 `reset()` 重置"""
60
+ _werewolf_interact_count: int = 0
61
+ """内部属性, 记录当晚狼人交互状态"""
60
62
  werewolf_finished: anyio.Event = dataclasses.field(default_factory=anyio.Event)
61
63
  """狼人交互是否结束"""
62
64
  killed: "Player | None" = None
63
65
  """当晚狼人击杀目标, `None` 则为空刀"""
64
- shoot: "Player | None" = None
66
+ shooter: "Player | None" = None
65
67
  """当前执行射杀操作的玩家"""
66
68
  antidote: set["Player"] = dataclasses.field(default_factory=set)
67
69
  """当晚女巫使用解药的目标"""
@@ -72,12 +74,22 @@ class GameState:
72
74
 
73
75
  def reset(self) -> None:
74
76
  self.werewolf_finished = anyio.Event()
77
+ self._werewolf_interact_count = 0
75
78
  self.killed = None
76
- self.shoot = None
79
+ self.shooter = None
77
80
  self.antidote = set()
78
81
  self.poison = set()
79
82
  self.protected = set()
80
83
 
84
+ def werewolf_start(self) -> None:
85
+ self._werewolf_interact_count += 1
86
+
87
+ def werewolf_end(self) -> bool:
88
+ self._werewolf_interact_count -= 1
89
+ if self._werewolf_interact_count == 0:
90
+ self.werewolf_finished.set()
91
+ return self.werewolf_finished.is_set()
92
+
81
93
 
82
94
  @dataclasses.dataclass
83
95
  class KillInfo: