"""游戏引擎:昼夜状态机、技能结算、警长竞选、胜负判定。 引擎通过 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, }