Files
werewolf/backend/app/game/engine.py
T

786 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""游戏引擎:昼夜状态机、技能结算、警长竞选、胜负判定。
引擎通过 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,
}