786 lines
35 KiB
Python
786 lines
35 KiB
Python
"""游戏引擎:昼夜状态机、技能结算、警长竞选、胜负判定。
|
||
|
||
引擎通过 Room 与客户端通信;AI 玩家的决策交给 agent(LLM 或启发式兜底)。
|
||
所有等待玩家操作的实现方式:注册一个 Pending(含每个席位的 Future),
|
||
人类玩家的提交通过 Room.handle_message 落到 handle_action / handle_speech,
|
||
AI 玩家则由后台任务调用 agent 后写入同一个 Future,超时按默认值处理。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import random
|
||
import time
|
||
|
||
from .player import Player
|
||
from .roles import ROLE_INFO, Role
|
||
from .board import Board
|
||
|
||
|
||
class Pending:
|
||
"""一次「等待若干席位提交操作」的登记。"""
|
||
|
||
def __init__(self, key: str, seats: list[int], options: list[int] | None, text: str, *,
|
||
allow_skip: bool = False, input_text: bool = False,
|
||
first_wins: bool = False, purpose: str = "") -> None:
|
||
self.key = key
|
||
self.seats = list(seats)
|
||
self.options = options
|
||
self.text = text
|
||
self.allow_skip = allow_skip
|
||
self.input_text = input_text
|
||
self.first_wins = first_wins # 狼队刀口:第一个提交者生效
|
||
self.purpose = purpose # speech 用:day / election
|
||
self.futures: dict[int, asyncio.Future] = {}
|
||
self.shared: asyncio.Future | None = None
|
||
if first_wins:
|
||
self.shared = asyncio.get_running_loop().create_future()
|
||
self.deadline = 0.0 # loop.time()
|
||
self.resolved = False
|
||
|
||
def seconds_left(self) -> int:
|
||
return max(0, int(self.deadline - time.monotonic()))
|
||
|
||
|
||
class Engine:
|
||
def __init__(self, room, board: Board, settings: dict) -> None:
|
||
self.room = room
|
||
self.board = board
|
||
self.settings = settings
|
||
self.running = True
|
||
self.reveal = False
|
||
self.day = 1
|
||
self.phase = "role_reveal"
|
||
self.phase_text = "身份分发中"
|
||
self.winner: str | None = None
|
||
self.winner_text: str | None = None
|
||
self.log: list[dict] = [] # {text, visible(None=公开|set[seat]), tag}
|
||
self.sheriff_seat: int | None = None
|
||
self.candidates: list[int] = []
|
||
self.claimed_seers: set[int] = set()
|
||
self.seer_results: dict[int, list[dict]] = {}
|
||
self.potions: dict[int, dict] = {}
|
||
self.guard_last: int | None = None
|
||
self.current_speaker: int | None = None
|
||
self.last_votes: list[dict] = []
|
||
self.pending_night_deaths: list[int] = []
|
||
self.night_death_causes: dict[int, str] = {}
|
||
self._pending: dict[str, Pending] = {}
|
||
self._agents: dict[int, object] = {}
|
||
|
||
# ================================================================ 工具
|
||
def alive_players(self) -> list[Player]:
|
||
return [self.room.players[s] for s in sorted(self.room.players) if self.room.players[s].alive]
|
||
|
||
def alive_seats(self) -> list[int]:
|
||
return [p.seat for p in self.alive_players()]
|
||
|
||
def player(self, seat: int) -> Player:
|
||
return self.room.players[seat]
|
||
|
||
def _alive_role(self, role: Role) -> Player | None:
|
||
for p in self.alive_players():
|
||
if p.role == role:
|
||
return p
|
||
return None
|
||
|
||
def _log(self, text: str, seat: int | None = None, tag: str = "system") -> None:
|
||
self.log.append({
|
||
"text": text,
|
||
"visible": None if seat is None else {seat},
|
||
"tag": tag,
|
||
})
|
||
|
||
def _log_public(self, text: str, tag: str = "system") -> None:
|
||
self._log(text, None, tag)
|
||
|
||
def _log_for(self, seats: set[int], text: str, tag: str = "system") -> None:
|
||
self.log.append({"text": text, "visible": set(seats), "tag": tag})
|
||
|
||
def _timeout(self, kind: str) -> int:
|
||
key = {"night": "night_seconds", "speech": "speech_seconds", "vote": "vote_seconds"}.get(kind, "vote_seconds")
|
||
return int(self.settings.get(key, 30))
|
||
|
||
def _agent(self, player: Player):
|
||
if player.seat not in self._agents:
|
||
from .ai import make_agent
|
||
self._agents[player.seat] = make_agent(player)
|
||
return self._agents[player.seat]
|
||
|
||
def is_candidate(self, seat: int) -> bool:
|
||
return seat in self.candidates
|
||
|
||
def visible_role_for(self, viewer_seat: int, target: Player) -> tuple[str | None, str | None]:
|
||
"""viewer 视角下 target 的角色是否可见(狼队友互见)。"""
|
||
viewer = self.room.players.get(viewer_seat)
|
||
if viewer and viewer.role == Role.WEREWOLF and target.role == Role.WEREWOLF:
|
||
return target.role.value, ROLE_INFO[target.role].name
|
||
return None, None
|
||
|
||
# ================================================================ 主流程
|
||
async def run(self) -> None:
|
||
try:
|
||
self._assign_roles()
|
||
await self._set_phase("role_reveal", "身份分发")
|
||
self._send_role_logs()
|
||
await asyncio.sleep(1.5)
|
||
while not self.winner:
|
||
await self._night()
|
||
if self._check_win():
|
||
break
|
||
await self._dawn()
|
||
if self._check_win():
|
||
break
|
||
if self.day == 1:
|
||
await self._sheriff_election()
|
||
await self._day_speeches()
|
||
await self._day_vote()
|
||
if self._check_win():
|
||
break
|
||
self.day += 1
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc: # 防止引擎任务静默死亡
|
||
self._log_public(f"游戏异常终止:{exc!r}")
|
||
self.winner = self.winner or "good"
|
||
self.winner_text = self.winner_text or "游戏异常结束"
|
||
finally:
|
||
self.running = False
|
||
self.reveal = True
|
||
self.phase = "game_over"
|
||
self.phase_text = "游戏结束"
|
||
self.current_speaker = None
|
||
self.candidates = []
|
||
self._pending.clear()
|
||
for p in self.room.players.values():
|
||
p.speaking = False
|
||
await self.room.broadcast_snapshot()
|
||
|
||
def _assign_roles(self) -> None:
|
||
roles = list(self.board.roles)
|
||
random.shuffle(roles)
|
||
for seat in sorted(self.room.players):
|
||
p = self.room.players[seat]
|
||
p.reset_for_new_game(roles.pop())
|
||
if p.role == Role.SEER:
|
||
self.seer_results[seat] = []
|
||
if p.role == Role.WITCH:
|
||
self.potions[seat] = {"antidote": True, "poison": True}
|
||
|
||
def _send_role_logs(self) -> None:
|
||
wolves = sorted(p.seat for p in self.room.players.values() if p.role == Role.WEREWOLF)
|
||
for p in self.room.players.values():
|
||
info = ROLE_INFO[p.role]
|
||
line = f"【你的身份】{info.emoji} {info.name}({p.seat}号)\n{info.description}"
|
||
if p.role == Role.WEREWOLF:
|
||
mates = [s for s in wolves if s != p.seat]
|
||
line += "\n你的狼队友:" + ("、".join(f"{s}号" for s in mates) if mates else "无(独狼)")
|
||
self._log(line, seat=p.seat, tag="identity")
|
||
|
||
def _check_win(self) -> bool:
|
||
alive = self.alive_players()
|
||
wolves = [p for p in alive if p.role == Role.WEREWOLF]
|
||
gods = [p for p in alive if p.role and ROLE_INFO[p.role].camp == "god"]
|
||
civs = [p for p in alive if p.role and ROLE_INFO[p.role].camp == "civilian"]
|
||
if not wolves:
|
||
self.winner = "good"
|
||
self.winner_text = "🎉 好人阵营胜利!所有狼人已被消灭。"
|
||
return True
|
||
rule = self.settings.get("win_rule", "bian")
|
||
if rule == "bian":
|
||
if not gods:
|
||
self.winner = "wolf"
|
||
self.winner_text = "🐺 狼人阵营胜利!(屠边:神职已全部出局)"
|
||
return True
|
||
if not civs:
|
||
self.winner = "wolf"
|
||
self.winner_text = "🐺 狼人阵营胜利!(屠边:村民已全部出局)"
|
||
return True
|
||
else:
|
||
if len(wolves) >= len(gods) + len(civs):
|
||
self.winner = "wolf"
|
||
self.winner_text = "🐺 狼人阵营胜利!(屠城:狼人数量不少于好人)"
|
||
return True
|
||
return False
|
||
|
||
async def _set_phase(self, phase: str, text: str) -> None:
|
||
self.phase = phase
|
||
self.phase_text = text
|
||
await self.room.broadcast_snapshot()
|
||
|
||
# ================================================================ 夜晚
|
||
async def _night(self) -> None:
|
||
self.last_votes = []
|
||
self.current_speaker = None
|
||
self.pending_night_deaths = []
|
||
self.night_death_causes = {}
|
||
await self._set_phase("night", f"第{self.day}夜 · 天黑请闭眼")
|
||
self._log_public(f"—— 第{self.day}夜 ——", "phase")
|
||
|
||
kill_target: int | None = None
|
||
saved = False
|
||
poison_target: int | None = None
|
||
|
||
# 守卫
|
||
guard = self._alive_role(Role.GUARD)
|
||
protect: int | None = None
|
||
if guard:
|
||
opts = [s for s in self.alive_seats() if s != self.guard_last]
|
||
await self._set_phase("night", f"第{self.day}夜 · 守卫请睁眼")
|
||
res = await self._wait("protect", [guard.seat], opts, self._timeout("night"),
|
||
"请选择今晚要守护的玩家(可空守,不能连守同一人)",
|
||
allow_skip=True)
|
||
protect = res.get(guard.seat)
|
||
if protect is not None:
|
||
self._log(f"你守护了 {protect} 号", seat=guard.seat, tag="private")
|
||
else:
|
||
self._log("你今晚选择空守", seat=guard.seat, tag="private")
|
||
|
||
# 狼人
|
||
wolves = [p for p in self.alive_players() if p.role == Role.WEREWOLF]
|
||
if wolves:
|
||
await self._set_phase("night", f"第{self.day}夜 · 狼人请睁眼")
|
||
wolf_seats = [w.seat for w in wolves]
|
||
victims = [s for s in self.alive_seats() if s not in wolf_seats]
|
||
kill_target = await self._wait_first_wins(
|
||
"kill", wolf_seats, victims, self._timeout("night"),
|
||
"狼队行动:选择今晚袭击的目标(队友先选的生效)")
|
||
if kill_target is not None:
|
||
self._log_for(set(wolf_seats), f"狼队决定袭击 {kill_target} 号", "private-wolf")
|
||
|
||
# 预言家
|
||
seer = self._alive_role(Role.SEER)
|
||
if seer:
|
||
results = self.seer_results.setdefault(seer.seat, [])
|
||
checked = {r["seat"] for r in results}
|
||
opts = [s for s in self.alive_seats() if s != seer.seat and s not in checked]
|
||
if opts:
|
||
await self._set_phase("night", f"第{self.day}夜 · 预言家请睁眼")
|
||
res = await self._wait("check", [seer.seat], opts, self._timeout("night"),
|
||
"请选择今晚要查验的玩家")
|
||
target = res.get(seer.seat)
|
||
if target is not None:
|
||
camp = "狼人" if self.player(target).role == Role.WEREWOLF else "好人"
|
||
results.append({"seat": target, "result": camp})
|
||
self._log(f"查验结果:{target} 号是「{camp}」", seat=seer.seat, tag="private")
|
||
|
||
# 女巫
|
||
witch = self._alive_role(Role.WITCH)
|
||
if witch:
|
||
pot = self.potions[witch.seat]
|
||
if pot["antidote"] or pot["poison"]:
|
||
await self._set_phase("night", f"第{self.day}夜 · 女巫请睁眼")
|
||
if pot["antidote"] and kill_target is not None:
|
||
can_act = kill_target != witch.seat or self.day == 1 # 首夜可自救
|
||
if can_act:
|
||
hint = f"今晚 {kill_target} 号被袭击了。" + ("(你可以在首夜自救)" if kill_target == witch.seat else "")
|
||
res = await self._wait("witch_save", [witch.seat], [kill_target],
|
||
self._timeout("night"), hint + " 是否使用解药?",
|
||
allow_skip=True)
|
||
if res.get(witch.seat) is not None:
|
||
pot["antidote"] = False
|
||
saved = True
|
||
self._log(f"你使用解药救下了 {kill_target} 号", seat=witch.seat, tag="private")
|
||
else:
|
||
self._log(f"你选择不救 {kill_target} 号", seat=witch.seat, tag="private")
|
||
else:
|
||
self._log("你今晚被袭击了(非首夜,无法自救)", seat=witch.seat, tag="private")
|
||
if pot["poison"] and not saved:
|
||
opts = [s for s in self.alive_seats() if s != witch.seat]
|
||
res = await self._wait("witch_poison", [witch.seat], opts,
|
||
self._timeout("night"), "是否使用毒药?选择要毒杀的玩家",
|
||
allow_skip=True)
|
||
poison_target = res.get(witch.seat)
|
||
if poison_target is not None:
|
||
pot["poison"] = False
|
||
self._log(f"你毒杀了 {poison_target} 号", seat=witch.seat, tag="private")
|
||
else:
|
||
self._log("你今晚没有用毒", seat=witch.seat, tag="private")
|
||
|
||
# 结算夜晚死亡
|
||
self.guard_last = protect
|
||
deaths: list[int] = []
|
||
if kill_target is not None:
|
||
if protect == kill_target and saved:
|
||
deaths.append(kill_target) # 同守同救
|
||
self.night_death_causes[kill_target] = "wolf"
|
||
elif protect == kill_target or saved:
|
||
pass
|
||
else:
|
||
deaths.append(kill_target)
|
||
self.night_death_causes[kill_target] = "wolf"
|
||
if poison_target is not None and poison_target not in deaths:
|
||
deaths.append(poison_target)
|
||
self.night_death_causes[poison_target] = "poison"
|
||
self.pending_night_deaths = deaths
|
||
|
||
async def _dawn(self) -> None:
|
||
await self._set_phase("dawn", f"第{self.day}天 · 天亮了")
|
||
self._log_public(f"—— 第{self.day}天 ——", "phase")
|
||
deaths = sorted(set(self.pending_night_deaths))
|
||
if not deaths:
|
||
self._log_public("昨夜是平安夜,无人出局。", "night")
|
||
else:
|
||
names = "、".join(f"{s}号" for s in deaths)
|
||
self._log_public(f"昨夜 {names} 出局。", "night")
|
||
for s in deaths:
|
||
p = self.player(s)
|
||
p.alive = False
|
||
cause = self.night_death_causes.get(s, "wolf")
|
||
self._log(f"你死了({'被毒杀' if cause == 'poison' else '被狼人袭击'})",
|
||
seat=s, tag="private")
|
||
await self.room.broadcast_snapshot()
|
||
|
||
if self.sheriff_seat is not None and self.sheriff_seat in deaths:
|
||
await self._badge_pass(self.sheriff_seat)
|
||
for s in deaths:
|
||
await self._maybe_hunter_shoot(s, poisoned=(self.night_death_causes.get(s) == "poison"))
|
||
|
||
async def _maybe_hunter_shoot(self, seat: int, *, poisoned: bool) -> None:
|
||
p = self.player(seat)
|
||
if p.role != Role.HUNTER:
|
||
return
|
||
if poisoned:
|
||
self._log("你被毒杀,无法开枪", seat=seat, tag="private")
|
||
return
|
||
opts = self.alive_seats()
|
||
if not opts:
|
||
return
|
||
await self._set_phase("dawn", f"{seat}号猎人 · 可发动技能")
|
||
self._log_public(f"{seat}号是猎人,可以开枪……", "system")
|
||
res = await self._wait("shoot", [seat], opts, self._timeout("vote"),
|
||
"你可以开枪带走一名玩家", allow_skip=True)
|
||
target = res.get(seat)
|
||
if target is None:
|
||
self._log_public(f"{seat}号猎人选择不开枪。")
|
||
return
|
||
victim = self.player(target)
|
||
victim.alive = False
|
||
self._log_public(f"🏹 {seat}号猎人开枪带走了 {target}号!", "night")
|
||
self._log("你死了(被猎人枪杀)", seat=target, tag="private")
|
||
await self.room.broadcast_snapshot()
|
||
if self.sheriff_seat == target:
|
||
await self._badge_pass(target)
|
||
|
||
async def _badge_pass(self, dead_sheriff: int) -> None:
|
||
self.sheriff_seat = None
|
||
opts = self.alive_seats()
|
||
if not opts:
|
||
return
|
||
await self._set_phase(self.phase, f"{dead_sheriff}号警长死亡 · 警徽流转")
|
||
self._log_public(f"{dead_sheriff}号警长的警徽等待移交……", "system")
|
||
res = await self._wait("badge", [dead_sheriff], opts, self._timeout("vote"),
|
||
"你可以将警徽移交给一名存活玩家,或撕毁警徽", allow_skip=True)
|
||
target = res.get(dead_sheriff)
|
||
if target is None:
|
||
self._log_public(f"{dead_sheriff}号撕毁了警徽,本局再无警长。")
|
||
else:
|
||
self.sheriff_seat = target
|
||
self._log_public(f"{dead_sheriff}号将警徽移交给了 {target}号。")
|
||
|
||
# ================================================================ 白天
|
||
async def _sheriff_election(self) -> None:
|
||
await self._set_phase("sheriff_elect", "警长竞选")
|
||
self._log_public("—— 警长竞选 ——", "phase")
|
||
alive = self.alive_seats()
|
||
res = await self._wait("elect", alive, [1], self._timeout("vote"),
|
||
"是否上警竞选警长?(警长投票计 1.5 票)", allow_skip=True)
|
||
self.candidates = [s for s in alive if res.get(s) == 1]
|
||
if not self.candidates:
|
||
self._log_public("无人上警,本局没有警长。")
|
||
self.candidates = []
|
||
await self.room.broadcast_snapshot()
|
||
return
|
||
if len(self.candidates) == 1:
|
||
self.sheriff_seat = self.candidates[0]
|
||
self._log_public(f"仅 {self.sheriff_seat}号 上警,自动当选警长。")
|
||
self._log("你当选了警长(投票计 1.5 票)", seat=self.sheriff_seat, tag="private")
|
||
self.candidates = []
|
||
await self.room.broadcast_snapshot()
|
||
return
|
||
|
||
self._log_public("上警玩家:" + "、".join(f"{s}号" for s in self.candidates))
|
||
for s in self.candidates:
|
||
if not self.player(s).alive:
|
||
continue
|
||
await self._speech_one(s, purpose="election")
|
||
|
||
voters = [s for s in self.alive_seats() if s not in self.candidates]
|
||
if not voters:
|
||
self.sheriff_seat = random.choice(self.candidates)
|
||
self._log_public(f"无人可投票,随机任命 {self.sheriff_seat}号 为警长。")
|
||
self._log("你当选了警长(投票计 1.5 票)", seat=self.sheriff_seat, tag="private")
|
||
self.candidates = []
|
||
await self.room.broadcast_snapshot()
|
||
return
|
||
|
||
await self._set_phase("sheriff_elect", "警长竞选 · 投票")
|
||
res = await self._wait("sheriff_vote", voters, list(self.candidates),
|
||
self._timeout("vote"), "请投票选出警长(不可弃票)")
|
||
self.last_votes = [{"voter": v, "target": t} for v, t in res.items() if t is not None]
|
||
counts: dict[int, float] = {}
|
||
for v, t in res.items():
|
||
if t is not None:
|
||
counts[t] = counts.get(t, 0) + 1.0
|
||
tally = ",".join(f"{s}号 {counts.get(s, 0):g} 票" for s in self.candidates)
|
||
top = max(counts.values()) if counts else 0
|
||
leaders = sorted([s for s, c in counts.items() if c == top])
|
||
if len(leaders) > 1:
|
||
self.sheriff_seat = random.choice(leaders)
|
||
self._log_public(f"警长竞选平票({tally}),随机由 {self.sheriff_seat}号 当选。")
|
||
else:
|
||
self.sheriff_seat = leaders[0]
|
||
self._log_public(f"{self.sheriff_seat}号当选警长!({tally})")
|
||
self._log("你当选了警长(投票计 1.5 票)", seat=self.sheriff_seat, tag="private")
|
||
self.candidates = []
|
||
await self.room.broadcast_snapshot()
|
||
|
||
async def _day_speeches(self) -> None:
|
||
await self._set_phase("speech", f"第{self.day}天 · 白天发言")
|
||
deaths = sorted(set(self.pending_night_deaths))
|
||
if deaths:
|
||
self._log_public("发言开始,从死者下家开始按座位顺序发言。", "phase")
|
||
start = deaths[0] % self.board.seat_count + 1
|
||
else:
|
||
base = self.sheriff_seat if self.sheriff_seat else 0
|
||
self._log_public("发言开始,从警长下家开始按座位顺序发言。", "phase")
|
||
start = base % self.board.seat_count + 1
|
||
alive = self.alive_seats()
|
||
order = [s for s in sorted(alive, key=lambda x: (x - start) % self.board.seat_count)]
|
||
for s in order:
|
||
if self.winner:
|
||
return
|
||
await self._speech_one(s, purpose="day")
|
||
|
||
async def _speech_one(self, seat: int, *, purpose: str) -> None:
|
||
p = self.player(seat)
|
||
if not p.alive:
|
||
return
|
||
p.speaking = True
|
||
self.current_speaker = seat
|
||
label = "竞选发言" if purpose == "election" else "发言"
|
||
await self._set_phase("speech", f"第{self.day}天 · {seat}号{label}中")
|
||
|
||
pend = Pending("speech", [seat], None, "轮到你发言了", input_text=True, purpose=purpose)
|
||
self._pending["speech"] = pend
|
||
loop = asyncio.get_running_loop()
|
||
fut = loop.create_future()
|
||
pend.futures[seat] = fut
|
||
pend.deadline = loop.time() + self._timeout("speech")
|
||
ai_task = None
|
||
if p.is_ai:
|
||
ai_task = asyncio.create_task(self._ai_speak(p, pend))
|
||
await self.room.broadcast_snapshot()
|
||
|
||
timeout = self._timeout("speech")
|
||
try:
|
||
await asyncio.wait_for(asyncio.shield(fut), timeout)
|
||
except asyncio.TimeoutError:
|
||
pass
|
||
finally:
|
||
self._pending.pop("speech", None)
|
||
if ai_task:
|
||
ai_task.cancel()
|
||
p.speaking = False
|
||
self.current_speaker = None
|
||
text = fut.result().strip() if fut.done() and not fut.cancelled() else ""
|
||
if not text:
|
||
text = "(过)"
|
||
self._log_public(f"{seat}号 {p.name}:{text}", "speech")
|
||
self._scan_claims(seat, text)
|
||
await self.room.broadcast_snapshot()
|
||
|
||
def _scan_claims(self, seat: int, text: str) -> None:
|
||
if ("预言家" in text) and ("我" in text or "本人" in text):
|
||
self.claimed_seers.add(seat)
|
||
|
||
async def _day_vote(self) -> None:
|
||
await self._set_phase("vote", f"第{self.day}天 · 投票放逐")
|
||
self._log_public("—— 投票 ——", "phase")
|
||
alive = self.alive_seats()
|
||
if len(alive) < 2:
|
||
return
|
||
res = await self._wait("vote", alive, alive, self._timeout("vote"),
|
||
"请投票放逐一名玩家(可弃票)", allow_skip=True)
|
||
self.last_votes = [{"voter": v, "target": t} for v, t in res.items()]
|
||
counts: dict[int, float] = {}
|
||
for v, t in res.items():
|
||
if t is None:
|
||
continue
|
||
weight = 1.5 if v == self.sheriff_seat else 1.0
|
||
counts[t] = counts.get(t, 0) + weight
|
||
detail = ",".join(f"{s}号 {counts.get(s, 0):g} 票" for s in alive if counts.get(s))
|
||
abstain = [v for v, t in res.items() if t is None]
|
||
if abstain:
|
||
detail += (";" if detail else "") + f"弃票:{'、'.join(str(v) + '号' for v in abstain)}"
|
||
self._log_public(f"投票结果:{detail or '无人投票'}", "vote")
|
||
await self.room.broadcast_snapshot()
|
||
|
||
if not counts:
|
||
self._log_public("无人得票,无人出局。")
|
||
return
|
||
top = max(counts.values())
|
||
leaders = sorted(s for s, c in counts.items() if c == top)
|
||
if len(leaders) > 1:
|
||
self._log_public(f"{'、'.join(str(s) + '号' for s in leaders)} 平票,本日无人出局。")
|
||
return
|
||
out = leaders[0]
|
||
victim = self.player(out)
|
||
victim.alive = False
|
||
self._log_public(f"⚖️ {out}号被投票放逐。", "vote")
|
||
self._log("你被投票放逐了", seat=out, tag="private")
|
||
await self.room.broadcast_snapshot()
|
||
if victim.role == Role.HUNTER:
|
||
await self._maybe_hunter_shoot(out, poisoned=False)
|
||
if self.sheriff_seat == out:
|
||
await self._badge_pass(out)
|
||
|
||
# ================================================================ 行动登记
|
||
def handle_action(self, seat: int, key: str | None, target) -> tuple[bool, str]:
|
||
if key is None:
|
||
return False, "缺少操作类型"
|
||
pend = self._pending.get(key)
|
||
if pend is None:
|
||
return False, "当前没有等待你的操作"
|
||
if pend.resolved:
|
||
return False, "该决定已完成(队友先提交了)"
|
||
if seat not in pend.seats:
|
||
return False, "现在不是你的行动阶段"
|
||
fut = pend.futures.get(seat)
|
||
if fut is not None and fut.done():
|
||
return False, "你已经提交过了"
|
||
if key == "vote" and target == seat:
|
||
return False, "不能投自己"
|
||
if target is None:
|
||
if not pend.allow_skip:
|
||
return False, "必须选择一个目标"
|
||
elif pend.options is not None and target not in pend.options:
|
||
return False, "无效的目标"
|
||
self._resolve(pend, seat, target)
|
||
return True, ""
|
||
|
||
def handle_speech(self, seat: int, text: str) -> tuple[bool, str]:
|
||
pend = self._pending.get("speech")
|
||
if pend is None:
|
||
return False, "当前没有轮到你发言"
|
||
if seat not in pend.seats:
|
||
return False, "当前没有轮到你发言"
|
||
fut = pend.futures.get(seat)
|
||
if fut is None or fut.done():
|
||
return False, "发言已提交"
|
||
text = (text or "").strip()[:300]
|
||
fut.set_result(text)
|
||
return True, ""
|
||
|
||
def _resolve(self, pend: Pending, seat: int, target) -> None:
|
||
if pend.resolved:
|
||
return
|
||
if pend.first_wins:
|
||
if pend.shared is not None and not pend.shared.done():
|
||
pend.shared.set_result(target)
|
||
pend.resolved = True
|
||
return
|
||
fut = pend.futures.get(seat)
|
||
if fut is not None and not fut.done():
|
||
fut.set_result(target)
|
||
|
||
async def _wait(self, key: str, seats: list[int], options: list[int] | None,
|
||
timeout: int, text: str, *, allow_skip: bool = False,
|
||
purpose: str = "") -> dict[int, int | None]:
|
||
pend = Pending(key, seats, options, text, allow_skip=allow_skip, purpose=purpose)
|
||
self._pending[key] = pend
|
||
loop = asyncio.get_running_loop()
|
||
pend.deadline = loop.time() + timeout
|
||
ai_tasks: list[asyncio.Task] = []
|
||
for s in seats:
|
||
p = self.player(s)
|
||
fut = loop.create_future()
|
||
pend.futures[s] = fut
|
||
if p.is_ai:
|
||
ai_tasks.append(asyncio.create_task(self._ai_act(p, pend)))
|
||
await self.room.broadcast_snapshot()
|
||
try:
|
||
await asyncio.wait_for(asyncio.gather(*pend.futures.values()), timeout=timeout)
|
||
except asyncio.TimeoutError:
|
||
pass
|
||
finally:
|
||
for t in ai_tasks:
|
||
t.cancel()
|
||
self._pending.pop(key, None)
|
||
results: dict[int, int | None] = {}
|
||
for s, fut in pend.futures.items():
|
||
try:
|
||
results[s] = fut.result() if fut.done() else None
|
||
except Exception:
|
||
results[s] = None
|
||
await self.room.broadcast_snapshot()
|
||
return results
|
||
|
||
async def _wait_first_wins(self, key: str, seats: list[int], options: list[int],
|
||
timeout: int, text: str) -> int | None:
|
||
pend = Pending(key, seats, options, text, allow_skip=True, first_wins=True)
|
||
self._pending[key] = pend
|
||
loop = asyncio.get_running_loop()
|
||
pend.deadline = loop.time() + timeout
|
||
ai_seats = [s for s in seats if self.player(s).is_ai]
|
||
ai_task = None
|
||
if ai_seats:
|
||
leader = random.choice(ai_seats) # 由一名 AI 狼主导提议,避免重复调用
|
||
ai_task = asyncio.create_task(self._ai_act(self.player(leader), pend))
|
||
await self.room.broadcast_snapshot()
|
||
try:
|
||
await asyncio.wait_for(asyncio.shield(pend.shared), timeout=timeout)
|
||
except asyncio.TimeoutError:
|
||
pass
|
||
finally:
|
||
if ai_task:
|
||
ai_task.cancel()
|
||
pend.resolved = True
|
||
self._pending.pop(key, None)
|
||
result = None
|
||
if pend.shared is not None and pend.shared.done() and not pend.shared.cancelled():
|
||
try:
|
||
result = pend.shared.result()
|
||
except Exception:
|
||
result = None
|
||
await self.room.broadcast_snapshot()
|
||
return result
|
||
|
||
# ================================================================ AI
|
||
def _ai_context(self, player: Player, pend: Pending | None = None, purpose: str = "") -> dict:
|
||
priv: list[str] = []
|
||
role = player.role
|
||
info = ROLE_INFO[role] if role else None
|
||
if role == Role.WEREWOLF:
|
||
mates = sorted(p.seat for p in self.room.players.values()
|
||
if p.role == Role.WEREWOLF and p.seat != player.seat)
|
||
priv.append("狼队友:" + ("、".join(f"{s}号" for s in mates) if mates else "无"))
|
||
if role == Role.SEER:
|
||
rs = self.seer_results.get(player.seat, [])
|
||
priv.append("查验记录:" + (";".join(f"{r['seat']}号是{r['result']}" for r in rs) if rs else "暂无"))
|
||
if role == Role.WITCH:
|
||
pot = self.potions[player.seat]
|
||
priv.append(f"解药{'还在' if pot['antidote'] else '已用'},毒药{'还在' if pot['poison'] else '已用'}")
|
||
if role == Role.GUARD:
|
||
priv.append(f"昨晚守护了{self.guard_last}号" if self.guard_last else "昨晚空守")
|
||
if self.sheriff_seat is not None:
|
||
priv.append(f"当前警长是{self.sheriff_seat}号(投票计1.5票)")
|
||
|
||
alive = self.alive_players()
|
||
log_lines = []
|
||
for e in self.log[-80:]:
|
||
if e["visible"] is None:
|
||
log_lines.append(e["text"])
|
||
log_text = "\n".join(log_lines)
|
||
if len(log_text) > 6000:
|
||
log_text = log_text[-6000:]
|
||
|
||
return {
|
||
"seat": player.seat,
|
||
"name": player.name,
|
||
"persona": player.persona,
|
||
"role": role.value if role else "",
|
||
"role_name": info.name if info else "",
|
||
"role_desc": info.description if info else "",
|
||
"private": priv,
|
||
"day": self.day,
|
||
"phase_text": self.phase_text,
|
||
"alive": [{"seat": p.seat, "name": p.name} for p in alive],
|
||
"options": pend.options if pend else [],
|
||
"key": pend.key if pend else "",
|
||
"question": pend.text if pend else "",
|
||
"purpose": purpose,
|
||
"claimed_seers": sorted(self.claimed_seers),
|
||
"sheriff_seat": self.sheriff_seat,
|
||
"log_text": log_text,
|
||
}
|
||
|
||
async def _ai_act(self, player: Player, pend: Pending) -> None:
|
||
try:
|
||
agent = self._agent(player)
|
||
ctx = self._ai_context(player, pend)
|
||
if pend.resolved:
|
||
return
|
||
target = await agent.decide(pend.key, ctx)
|
||
if target is not None and pend.options is not None and target not in pend.options:
|
||
target = None if pend.allow_skip else random.choice(pend.options)
|
||
self._resolve(pend, player.seat, target)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
if not pend.resolved and pend.allow_skip:
|
||
self._resolve(pend, player.seat, None)
|
||
|
||
async def _ai_speak(self, player: Player, pend: Pending) -> None:
|
||
try:
|
||
agent = self._agent(player)
|
||
ctx = self._ai_context(player, pend, purpose=pend.purpose)
|
||
text = await agent.speak(ctx)
|
||
fut = pend.futures.get(player.seat)
|
||
if fut is not None and not fut.done():
|
||
fut.set_result((text or "").strip()[:300])
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
pass
|
||
|
||
# ================================================================ 快照
|
||
def snapshot_for(self, seat: int | None) -> dict:
|
||
players_game = {}
|
||
# 由 Room.snapshot_for 组装玩家视图,这里只补充对局相关字段
|
||
pend_view = None
|
||
if seat is not None:
|
||
for pend in self._pending.values():
|
||
if pend.resolved or seat not in pend.seats:
|
||
continue
|
||
fut = pend.futures.get(seat)
|
||
if fut is not None and fut.done():
|
||
continue
|
||
if pend.input_text:
|
||
pend_view = {
|
||
"key": pend.key, "options": [], "text": pend.text,
|
||
"allow_skip": False, "input_text": True,
|
||
"purpose": pend.purpose, "seconds_left": pend.seconds_left(),
|
||
}
|
||
else:
|
||
pend_view = {
|
||
"key": pend.key,
|
||
"options": pend.options or [],
|
||
"text": pend.text,
|
||
"allow_skip": pend.allow_skip,
|
||
"input_text": False,
|
||
"purpose": "",
|
||
"seconds_left": pend.seconds_left(),
|
||
}
|
||
break
|
||
|
||
me = None
|
||
if seat is not None:
|
||
p = self.room.players.get(seat)
|
||
if p is not None and p.role is not None:
|
||
me = {"seat": seat}
|
||
if p.role == Role.WEREWOLF:
|
||
me["teammates"] = sorted(q.seat for q in self.room.players.values()
|
||
if q.role == Role.WEREWOLF and q.seat != seat)
|
||
if p.role == Role.SEER:
|
||
me["seer_results"] = self.seer_results.get(seat, [])
|
||
if p.role == Role.WITCH:
|
||
me["potions"] = self.potions.get(seat)
|
||
|
||
log_view = []
|
||
for e in self.log:
|
||
if e["visible"] is None or (seat is not None and seat in e["visible"]):
|
||
log_view.append({"text": e["text"], "kind": "private" if e["visible"] else "public",
|
||
"tag": e["tag"]})
|
||
|
||
return {
|
||
"me_extras": me,
|
||
"log": log_view,
|
||
"pending": pend_view,
|
||
"sheriff_seat": self.sheriff_seat,
|
||
"candidates": list(self.candidates),
|
||
"current_speaker": self.current_speaker,
|
||
"last_votes": self.last_votes,
|
||
"winner": self.winner,
|
||
"winner_text": self.winner_text,
|
||
}
|