Initial commit: 网页联机狼人杀(FastAPI+WebSocket 后端 / React+TS+Sass 前端)

This commit is contained in:
hhhge committed 2026-10-03 20:47:44 +08:00
commit 7f5ea6bee8
55 files changed
+6999

No files matched your search

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