nonebot-plugin-werewolf 1.0.1__tar.gz → 1.0.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nonebot-plugin-werewolf
3
- Version: 1.0.1
3
+ Version: 1.0.3
4
4
  Summary: Default template for PDM package
5
5
  Author-Email: wyf7685 <wyf7685@163.com>
6
6
  License: MIT
@@ -12,9 +12,9 @@ Requires-Dist: nonebot-plugin-waiter>=0.7.1
12
12
  Description-Content-Type: text/markdown
13
13
 
14
14
  <div align="center">
15
- <a href="https://v2.nonebot.dev/store"><img src="https://github.com/A-kirami/nonebot-plugin-template/blob/resources/nbp_logo.png" width="180" height="180" alt="NoneBotPluginLogo"></a>
16
- <br>
17
- <p><img src="https://github.com/A-kirami/nonebot-plugin-template/blob/resources/NoneBotPlugin.svg" width="240" alt="NoneBotPluginText"></p>
15
+ <a href="https://v2.nonebot.dev/store">
16
+ <img src="https://github.com/wyf7685/wyf7685/blob/main/assets/NoneBotPlugin.svg" width="300" alt="logo">
17
+ </a>
18
18
  </div>
19
19
 
20
20
  <div align="center">
@@ -24,7 +24,7 @@ Description-Content-Type: text/markdown
24
24
  _✨ 简单的狼人杀插件 ✨_
25
25
 
26
26
  [![license](https://img.shields.io/github/license/wyf7685/nonebot-plugin-werewolf.svg)](./LICENSE)
27
- [![pypi](https://img.shields.io/pypi/v/nonebot-plugin-werewolf?logo=python&logoColor=edb641)](https://pypi.python.org/pypi/nonebot-plugin-exe-code)
27
+ [![pypi](https://img.shields.io/pypi/v/nonebot-plugin-werewolf?logo=python&logoColor=edb641)](https://pypi.python.org/pypi/nonebot-plugin-werewolf)
28
28
  [![python](https://img.shields.io/badge/python-3.10+-blue?logo=python&logoColor=edb641)](https://www.python.org/)
29
29
 
30
30
  [![pdm-managed](https://img.shields.io/endpoint?url=https%3A%2F%2Fcdn.jsdelivr.net%2Fgh%2Fpdm-project%2F.github%2Fbadge.json)](https://pdm-project.org)
@@ -143,7 +143,9 @@ _✨ 简单的狼人杀插件 ✨_
143
143
  职业预设可以通过配置项 `werewolf__override_preset` 修改
144
144
 
145
145
  <details>
146
- <summary>配置项 `werewolf__override_preset` 示例</summary>
146
+ <summary>示例</summary>
147
+
148
+ 配置项 `werewolf__override_preset`
147
149
 
148
150
  ```env
149
151
  werewolf__override_preset='
@@ -169,6 +171,10 @@ werewolf__override_preset='
169
171
  <details>
170
172
  <summary>更新日志</summary>
171
173
 
174
+ - 2024.09.01 v1.0.3
175
+
176
+ - 优化玩家交互体验
177
+
172
178
  - 2024.08.31 v1.0.1
173
179
 
174
180
  - 允许通过配置项修改职业预设
@@ -1,7 +1,7 @@
1
1
  <div align="center">
2
- <a href="https://v2.nonebot.dev/store"><img src="https://github.com/A-kirami/nonebot-plugin-template/blob/resources/nbp_logo.png" width="180" height="180" alt="NoneBotPluginLogo"></a>
3
- <br>
4
- <p><img src="https://github.com/A-kirami/nonebot-plugin-template/blob/resources/NoneBotPlugin.svg" width="240" alt="NoneBotPluginText"></p>
2
+ <a href="https://v2.nonebot.dev/store">
3
+ <img src="https://github.com/wyf7685/wyf7685/blob/main/assets/NoneBotPlugin.svg" width="300" alt="logo">
4
+ </a>
5
5
  </div>
6
6
 
7
7
  <div align="center">
@@ -11,7 +11,7 @@
11
11
  _✨ 简单的狼人杀插件 ✨_
12
12
 
13
13
  [![license](https://img.shields.io/github/license/wyf7685/nonebot-plugin-werewolf.svg)](./LICENSE)
14
- [![pypi](https://img.shields.io/pypi/v/nonebot-plugin-werewolf?logo=python&logoColor=edb641)](https://pypi.python.org/pypi/nonebot-plugin-exe-code)
14
+ [![pypi](https://img.shields.io/pypi/v/nonebot-plugin-werewolf?logo=python&logoColor=edb641)](https://pypi.python.org/pypi/nonebot-plugin-werewolf)
15
15
  [![python](https://img.shields.io/badge/python-3.10+-blue?logo=python&logoColor=edb641)](https://www.python.org/)
16
16
 
17
17
  [![pdm-managed](https://img.shields.io/endpoint?url=https%3A%2F%2Fcdn.jsdelivr.net%2Fgh%2Fpdm-project%2F.github%2Fbadge.json)](https://pdm-project.org)
@@ -130,7 +130,9 @@ _✨ 简单的狼人杀插件 ✨_
130
130
  职业预设可以通过配置项 `werewolf__override_preset` 修改
131
131
 
132
132
  <details>
133
- <summary>配置项 `werewolf__override_preset` 示例</summary>
133
+ <summary>示例</summary>
134
+
135
+ 配置项 `werewolf__override_preset`
134
136
 
135
137
  ```env
136
138
  werewolf__override_preset='
@@ -156,6 +158,10 @@ werewolf__override_preset='
156
158
  <details>
157
159
  <summary>更新日志</summary>
158
160
 
161
+ - 2024.09.01 v1.0.3
162
+
163
+ - 优化玩家交互体验
164
+
159
165
  - 2024.08.31 v1.0.1
160
166
 
161
167
  - 允许通过配置项修改职业预设
@@ -8,7 +8,7 @@ require("nonebot_plugin_waiter")
8
8
  from . import matchers as matchers
9
9
  from .config import Config
10
10
 
11
- __version__ = "1.0.1"
11
+ __version__ = "1.0.3"
12
12
  __plugin_meta__ = PluginMetadata(
13
13
  name="狼人杀",
14
14
  description="适用于 Nonebot2 的狼人杀插件",
@@ -44,6 +44,7 @@ class GameStatus(Enum):
44
44
 
45
45
  @dataclass
46
46
  class GameState:
47
+ day: int
47
48
  killed: "Player | None" = None
48
49
  shoot: tuple["Player", "Player"] | tuple[None, None] = (None, None)
49
50
  protected: "Player | None" = None
@@ -1,13 +1,16 @@
1
1
  import asyncio
2
2
  import asyncio.timeouts
3
3
  import random
4
- from collections.abc import Callable
5
4
 
6
5
  from nonebot.adapters import Bot
7
6
  from nonebot_plugin_alconna import Target, UniMessage
8
7
 
9
8
  from .constant import GameState, GameStatus, KillReason, Role, RoleGroup, player_preset
10
- from .player import Player, PlayerSet
9
+ from .player import Player
10
+ from .player_set import PlayerSet
11
+
12
+ starting_games: dict[str, dict[str, str]] = {}
13
+ running_games: dict[str, tuple["Game", asyncio.Task[None]]] = {}
11
14
 
12
15
 
13
16
  def init_players(bot: Bot, game: "Game", players: dict[str, str]) -> PlayerSet:
@@ -15,7 +18,7 @@ def init_players(bot: Bot, game: "Game", players: dict[str, str]) -> PlayerSet:
15
18
  if preset is None:
16
19
  raise ValueError(
17
20
  f"玩家人数不符: "
18
- f"应为{min(player_preset)}-{max(player_preset)}人, 传入{len(players)}人"
21
+ f"应为 {', '.join(map(str, player_preset))} 人, 传入{len(players)}人"
19
22
  )
20
23
 
21
24
  roles: list[Role] = []
@@ -50,20 +53,18 @@ class Game:
50
53
  group: Target
51
54
  players: PlayerSet
52
55
  state: GameState
53
- _on_exit: Callable[[], None]
56
+ killed_players: list[Player]
54
57
 
55
58
  def __init__(
56
59
  self,
57
60
  bot: Bot,
58
61
  group: Target,
59
62
  players: dict[str, str],
60
- on_exit: Callable[[], None],
61
63
  ) -> None:
62
64
  self.bot = bot
63
65
  self.group = group
64
66
  self.players = init_players(bot, self, players)
65
- self.state = GameState()
66
- self._on_exit = on_exit
67
+ self.state = GameState(0)
67
68
 
68
69
  async def send(self, message: str | UniMessage):
69
70
  if isinstance(message, str):
@@ -78,27 +79,49 @@ class Game:
78
79
 
79
80
  def check_game_status(self) -> GameStatus:
80
81
  players = self.players.alive()
81
-
82
82
  w = players.select(RoleGroup.狼人)
83
- if not w.size:
84
- return GameStatus.Good
85
-
86
83
  p = players.exclude(RoleGroup.狼人)
84
+
87
85
  if w.size >= p.size:
88
86
  return GameStatus.Bad
89
- if not players.select(Role.平民):
87
+ if not p.select(Role.平民):
90
88
  return GameStatus.Bad
91
- if not players.select(RoleGroup.好人).exclude(Role.平民):
89
+ if not p.exclude(Role.平民):
92
90
  return GameStatus.Bad
91
+ if not w.size:
92
+ return GameStatus.Good
93
93
 
94
94
  return GameStatus.Unset
95
95
 
96
+ def show_killed_players(self) -> str:
97
+ msg = ""
98
+
99
+ for player in self.killed_players:
100
+ if player.kill_info is None:
101
+ continue
102
+
103
+ msg += f"{player.name} 被 " + ", ".join(
104
+ p.name for p in player.kill_info.killers
105
+ )
106
+ match player.kill_info.reason:
107
+ case KillReason.Kill:
108
+ msg += " 刀了"
109
+ case KillReason.Poison:
110
+ msg += " 毒死"
111
+ case KillReason.Shoot:
112
+ msg += " 射杀"
113
+ case KillReason.Vote:
114
+ msg += " 投票放逐"
115
+ msg += "\n\n"
116
+
117
+ return msg.strip()
118
+
96
119
  async def notify_player_role(self) -> None:
97
120
  preset = player_preset[len(self.players)]
98
121
  await asyncio.gather(
99
122
  self.send(
100
123
  self.at_all()
101
- .text("\n正在分配职业,请注意查看私聊消息\n")
124
+ .text("\n\n正在分配职业,请注意查看私聊消息\n")
102
125
  .text(f"当前玩家数: {len(self.players)}\n")
103
126
  .text(f"职业分配: 狼人x{preset[0]}, 神职x{preset[1]}, 平民x{preset[2]}")
104
127
  ),
@@ -125,8 +148,10 @@ class Game:
125
148
  type_.role.name # Player
126
149
  if isinstance(type_, Player)
127
150
  else (
128
- type_.name if isinstance(type_, Role) else f"{type_.name}阵营" # Role
129
- ) # RoleGroup
151
+ type_.name # Role
152
+ if isinstance(type_, Role)
153
+ else f"{type_.name}阵营" # RoleGroup
154
+ )
130
155
  )
131
156
 
132
157
  await players.broadcast(f"{text}交互开始,限时 {timeout_secs/60:.2f} 分钟")
@@ -176,9 +201,10 @@ class Game:
176
201
  if not players:
177
202
  return
178
203
 
179
- for player in players:
204
+ for player in players.dead():
180
205
  await player.post_kill()
181
206
  await self.handle_new_dead(player)
207
+ self.killed_players.append(player)
182
208
 
183
209
  (shooter, shoot) = self.state.shoot
184
210
  if shooter is not None and shoot is not None:
@@ -189,27 +215,32 @@ class Game:
189
215
  .text("限时1分钟, 发送 “/stop” 结束发言")
190
216
  )
191
217
  await self.wait_stop(shoot, 60)
218
+ await self.post_kill(shoot)
192
219
  self.state.shoot = (None, None)
193
220
 
194
221
  async def run_vote(self) -> None:
195
222
  # 统计投票结果
196
- vote_result: dict[Player | None, int] = {}
223
+ players = self.players.alive()
224
+
225
+ # 被票玩家: [投票玩家]
226
+ vote_result: dict[Player, list[Player]] = await players.vote(60)
227
+ # 票数: [被票玩家]
197
228
  vote_reversed: dict[int, list[Player]] = {}
198
- for p in await self.players.alive().vote(60):
199
- vote_result[p] = vote_result.get(p, 0) + 1
229
+ # 收集到的总票数
230
+ total_votes = sum(map(len, vote_result.values()))
200
231
 
201
232
  # 投票结果公示
202
233
  msg = UniMessage.text("投票结果:\n")
203
234
  for p, v in sorted(vote_result.items(), key=lambda x: x[1], reverse=True):
204
235
  if p is not None:
205
236
  msg.at(p.user_id).text(f": {v} 票\n")
206
- vote_reversed[v] = [*vote_reversed.get(v, []), p]
207
- if v := vote_result.get(None, 0):
237
+ vote_reversed[len(v)] = [*vote_reversed.get(len(v), []), p]
238
+ if v := (len(players) - total_votes):
208
239
  msg.text(f"弃票: {v} 票\n")
209
240
  await self.send(msg)
210
241
 
211
242
  # 全员弃票 # 不是哥们?
212
- if not vote_reversed:
243
+ if total_votes == 0:
213
244
  await self.send("没有人被票出")
214
245
  return
215
246
 
@@ -224,7 +255,7 @@ class Game:
224
255
 
225
256
  # 仅有一名玩家票数最高
226
257
  voted = vs.pop()
227
- if not await voted.kill(KillReason.Vote):
258
+ if not await voted.kill(KillReason.Vote, *vote_result[voted]):
228
259
  # 投票放逐失败 (例: 白痴)
229
260
  return
230
261
 
@@ -267,7 +298,7 @@ class Game:
267
298
 
268
299
  while self.check_game_status() == GameStatus.Unset:
269
300
  # 重置游戏状态,进入下一夜
270
- self.state = GameState()
301
+ self.state = GameState(day_count)
271
302
  players = self.players.alive()
272
303
  await self.send("天黑请闭眼...")
273
304
 
@@ -290,19 +321,19 @@ class Game:
290
321
  if killed is not None:
291
322
  # 除非守卫保护或女巫使用解药,否则狼人正常击杀玩家
292
323
  if not ((killed is protected) or (antidote and potioned is killed)):
293
- await killed.kill(KillReason.Kill)
324
+ await killed.kill(KillReason.Kill, *players.select(RoleGroup.狼人))
294
325
  # 如果女巫使用毒药且守卫未保护,杀死该玩家
295
326
  if poison and (potioned is not None) and (potioned is not protected):
296
- await potioned.kill(KillReason.Poison)
327
+ await potioned.kill(KillReason.Poison, *players.select(Role.女巫))
297
328
 
298
329
  day_count += 1
299
- msg = UniMessage.text(f"【第{day_count}天】天亮了...\n")
330
+ msg = UniMessage.text(f"『第{day_count}天』天亮了...\n")
300
331
  # 没有玩家死亡,平安夜
301
332
  if not (dead := players.dead()):
302
333
  await self.send(msg.text("昨晚是平安夜"))
303
334
  # 有玩家死亡,执行死亡流程
304
335
  else:
305
- # 公开死者名单
336
+ # 公布死者名单
306
337
  msg.text("昨晚的死者是:")
307
338
  for p in dead.sorted():
308
339
  msg.text("\n").at(p.user_id)
@@ -336,8 +367,9 @@ class Game:
336
367
  # 游戏结束
337
368
  dead_channel.cancel()
338
369
  winner = "好人" if self.check_game_status() == GameStatus.Good else "狼人"
339
- msg = UniMessage.text(f"游戏结束,{winner}获胜\n\n")
340
- for p in sorted(self.players, key=lambda p: (p.role.name, p.user_id)):
370
+ msg = UniMessage.text(f"🎉游戏结束,{winner}获胜\n\n")
371
+ for p in sorted(self.players, key=lambda p: (p.role.value, p.user_id)):
341
372
  msg.at(p.user_id).text(f": {p.role.name}\n")
373
+ msg.text(f"\n{self.show_killed_players()}")
342
374
  await self.send(msg)
343
- self._on_exit()
375
+ running_games.pop(self.group.id, None)
@@ -0,0 +1,66 @@
1
+ import asyncio
2
+ import asyncio.timeouts
3
+ from typing import Annotated
4
+
5
+ from nonebot import on_command, on_message
6
+ from nonebot.adapters import Bot, Event
7
+ from nonebot.rule import to_me
8
+ from nonebot_plugin_alconna import MsgTarget, UniMessage, UniMsg
9
+ from nonebot_plugin_userinfo import EventUserInfo, UserInfo
10
+
11
+ from .game import Game, running_games, starting_games
12
+ from .ob11_ext import ob11_ext_enabled
13
+ from .utils import InputStore, is_group, prepare_game, rule_in_game, rule_not_in_game
14
+
15
+ in_game_message = on_message(rule=rule_in_game)
16
+ start_game = on_command(
17
+ "werewolf",
18
+ rule=to_me() & is_group & rule_not_in_game,
19
+ aliases={"狼人杀"},
20
+ )
21
+
22
+
23
+ @in_game_message.handle()
24
+ async def handle_input(event: Event, target: MsgTarget, msg: UniMsg) -> None:
25
+ if target.private:
26
+ InputStore.put(target.id, None, msg)
27
+ else:
28
+ InputStore.put(event.get_user_id(), target.id, msg)
29
+
30
+
31
+ @start_game.handle()
32
+ async def handle_start(
33
+ bot: Bot,
34
+ event: Event,
35
+ target: MsgTarget,
36
+ admin_info: Annotated[UserInfo, EventUserInfo()],
37
+ ) -> None:
38
+ if target.id in running_games:
39
+ await UniMessage.text("当前群聊内有正在进行的游戏,无法创建游戏").finish()
40
+
41
+ admin_id = event.get_user_id()
42
+ msg = (
43
+ UniMessage.at(admin_id)
44
+ .text("成功创建游戏\n")
45
+ .text("玩家请 @我 发送 “加入游戏”、“退出游戏”\n")
46
+ .text("玩家 @我 发送 “当前玩家” 可查看玩家列表\n")
47
+ .text("游戏发起者 @我 发送 “结束游戏” 可结束当前游戏\n")
48
+ .text("玩家均加入后,游戏发起者请 @我 发送 “开始游戏”\n")
49
+ )
50
+ if ob11_ext_enabled():
51
+ msg.text("\n可使用戳一戳代替游戏交互中的 “/stop” 命令")
52
+ await msg.text("\n\n游戏准备阶段限时5分钟,超时将自动结束").send()
53
+
54
+ players = starting_games[target.id] = {admin_id: admin_info.user_name}
55
+
56
+ try:
57
+ async with asyncio.timeouts.timeout(5 * 60):
58
+ await prepare_game(event, players)
59
+ except TimeoutError:
60
+ await UniMessage.text("游戏准备超时,已自动结束").finish()
61
+ finally:
62
+ del starting_games[target.id]
63
+
64
+ game = Game(bot=bot, group=target, players=players)
65
+ task = asyncio.create_task(game.run())
66
+ running_games[target.id] = (game, task)
@@ -0,0 +1,72 @@
1
+ import contextlib
2
+
3
+ from nonebot import on_type
4
+ from nonebot.internal.matcher import current_bot
5
+ from nonebot_plugin_alconna import UniMessage
6
+
7
+ from .config import config
8
+ from .game import starting_games
9
+ from .utils import InputStore, user_in_game
10
+
11
+
12
+ def ob11_ext_enabled() -> bool:
13
+ return False
14
+
15
+
16
+ with contextlib.suppress(ImportError):
17
+ from nonebot.adapters.onebot.v11 import Bot, MessageSegment
18
+ from nonebot.adapters.onebot.v11.event import PokeNotifyEvent
19
+
20
+ # 游戏内戳一戳等效 "/stop"
21
+ async def _rule_poke_1(event: PokeNotifyEvent) -> bool:
22
+ if not config.enable_poke:
23
+ return False
24
+
25
+ user_id = str(event.user_id)
26
+ group_id = str(event.group_id) if event.group_id is not None else None
27
+ return (
28
+ config.enable_poke
29
+ and (event.target_id == event.self_id)
30
+ and user_in_game(user_id, group_id)
31
+ )
32
+
33
+ @on_type(PokeNotifyEvent, rule=_rule_poke_1).handle()
34
+ async def handle_poke_1(event: PokeNotifyEvent) -> None:
35
+ InputStore.put(
36
+ user_id=str(event.user_id),
37
+ group_id=str(event.group_id) if event.group_id is not None else None,
38
+ msg=UniMessage.text("/stop"),
39
+ )
40
+
41
+ # 准备阶段戳一戳等效加入游戏
42
+ async def _rule_poke_2(event: PokeNotifyEvent) -> bool:
43
+ if not config.enable_poke or event.group_id is None:
44
+ return False
45
+
46
+ user_id = str(event.user_id)
47
+ group_id = str(event.group_id)
48
+ return (
49
+ (event.target_id == event.self_id)
50
+ and not user_in_game(user_id, group_id)
51
+ and group_id in starting_games
52
+ )
53
+
54
+ @on_type(PokeNotifyEvent, rule=_rule_poke_2).handle()
55
+ async def handle_poke_2(bot: Bot, event: PokeNotifyEvent) -> None:
56
+ user_id = str(event.user_id)
57
+ group_id = str(event.group_id)
58
+ players = starting_games[group_id]
59
+
60
+ if user_id not in players:
61
+ res: dict[str, str] = await bot.get_group_member_info(
62
+ group_id=int(group_id),
63
+ user_id=int(user_id),
64
+ )
65
+ players[user_id] = res.get("nickname") or user_id
66
+ await bot.send(event, MessageSegment.at(user_id) + "成功加入游戏")
67
+
68
+ def ob11_ext_enabled() -> bool:
69
+ if not config.enable_poke:
70
+ return False
71
+
72
+ return isinstance(current_bot.get(), Bot)